feat(taiga): Bulk-Abfrage und Abweichungs-Marke — das Diagramm zeigt, wo Ticket und Plan auseinanderlaufen (D91-Nachtrag 10, SPEC §9)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e77a4d1ce4
commit
a1e4a3bbab
@@ -81,6 +81,15 @@ docs/SPEC.md §10 testen — niemals eine zweite, abweichende Grammatik pflegen.
|
|||||||
deshalb den `slug` (aus `&taiga.<slug>`, SPEC §1) und fragen erst
|
deshalb den `slug` (aus `&taiga.<slug>`, SPEC §1) und fragen erst
|
||||||
`/projects/by_slug`, dann `by_ref` — der Slug kommt vom Client und wird
|
`/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.
|
**kodiert** angehängt, sonst hängte ein `&` darin einen weiteren Filter an.
|
||||||
|
- **Bulk-Lesen (D91-Nachtrag 10):** `GET /taiga/tickets?slug=&refs=US-1,T-2`
|
||||||
|
fächert serverseitig in `by_ref`-Einzelabfragen auf — parallel (virtuelle
|
||||||
|
Threads) mit Semaphore-Deckel (6): Die Kosten skalieren mit den Refs im
|
||||||
|
Plan, nie mit der Projektgröße; eine Projekt-Volliste ist bei Tausenden
|
||||||
|
Tickets die falsche Bulk-Einheit. Eine 404-Ref fehlt still in der
|
||||||
|
Antwort-Map, jeder andere Fehler bricht die Anfrage ab; ungültige Refs
|
||||||
|
sind ein 400 (`TaigaBadRequestException`), Deckel 200, Dubletten werden
|
||||||
|
zusammengelegt. Messnotiz: Ob Taigas Listen-Endpunkte eine Ref-Liste als
|
||||||
|
Filter nehmen, ist offen — falls ja, tauscht nur das Innere des Proxys.
|
||||||
- **Schreiben (D91-Nachtrag 8):** `PATCH /taiga/{userstories|tasks}/{ref}/status`
|
- **Schreiben (D91-Nachtrag 8):** `PATCH /taiga/{userstories|tasks}/{ref}/status`
|
||||||
nimmt die Status-**Id** (aus `GET /taiga/{userstory|task}-statuses?slug=`)
|
nimmt die Status-**Id** (aus `GET /taiga/{userstory|task}-statuses?slug=`)
|
||||||
und die zuletzt gelesene `version` — Taigas optimistische Sperre; ein
|
und die zuletzt gelesene `version` — Taigas optimistische Sperre; ein
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import de.werkbaum.service.DocumentDeletedException
|
|||||||
import de.werkbaum.service.DocumentNotFoundException
|
import de.werkbaum.service.DocumentNotFoundException
|
||||||
import de.werkbaum.service.InvalidPatchException
|
import de.werkbaum.service.InvalidPatchException
|
||||||
import de.werkbaum.service.StalePatchSequenceException
|
import de.werkbaum.service.StalePatchSequenceException
|
||||||
|
import de.werkbaum.integration.taiga.TaigaBadRequestException
|
||||||
import de.werkbaum.integration.taiga.TaigaNotConfiguredException
|
import de.werkbaum.integration.taiga.TaigaNotConfiguredException
|
||||||
import de.werkbaum.integration.taiga.TaigaUnavailableException
|
import de.werkbaum.integration.taiga.TaigaUnavailableException
|
||||||
import de.werkbaum.integration.taiga.TaigaUpstreamException
|
import de.werkbaum.integration.taiga.TaigaUpstreamException
|
||||||
@@ -89,6 +90,14 @@ class GlobalExceptionHandler {
|
|||||||
ex.message ?: "Taiga nicht konfiguriert",
|
ex.message ?: "Taiga nicht konfiguriert",
|
||||||
).apply { title = "Taiga nicht konfiguriert" }
|
).apply { title = "Taiga nicht konfiguriert" }
|
||||||
|
|
||||||
|
/** Eine Anfrage, die schon der Proxy ablehnt (Refs-Liste der Bulk-Abfrage). */
|
||||||
|
@ExceptionHandler(TaigaBadRequestException::class)
|
||||||
|
fun handleTaigaBadRequest(ex: TaigaBadRequestException): ProblemDetail =
|
||||||
|
ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
ex.message ?: "Ungültige Anfrage",
|
||||||
|
).apply { title = "Ungültige Anfrage" }
|
||||||
|
|
||||||
@ExceptionHandler(TaigaUnavailableException::class)
|
@ExceptionHandler(TaigaUnavailableException::class)
|
||||||
fun handleTaigaUnavailable(ex: TaigaUnavailableException): ProblemDetail =
|
fun handleTaigaUnavailable(ex: TaigaUnavailableException): ProblemDetail =
|
||||||
ProblemDetail.forStatusAndDetail(
|
ProblemDetail.forStatusAndDetail(
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import de.werkbaum.generated.model.TaigaStatus
|
|||||||
import de.werkbaum.generated.model.TaigaStatusPatch
|
import de.werkbaum.generated.model.TaigaStatusPatch
|
||||||
import de.werkbaum.generated.model.TaigaTicket
|
import de.werkbaum.generated.model.TaigaTicket
|
||||||
import de.werkbaum.generated.model.TaigaTicketDetail
|
import de.werkbaum.generated.model.TaigaTicketDetail
|
||||||
|
import de.werkbaum.integration.taiga.TaigaBadRequestException
|
||||||
|
import de.werkbaum.integration.taiga.TaigaBulkRefData
|
||||||
import de.werkbaum.integration.taiga.TaigaClient
|
import de.werkbaum.integration.taiga.TaigaClient
|
||||||
import de.werkbaum.integration.taiga.TaigaTicketDetailData
|
import de.werkbaum.integration.taiga.TaigaTicketDetailData
|
||||||
import de.werkbaum.integration.taiga.TaigaTicketData
|
import de.werkbaum.integration.taiga.TaigaTicketData
|
||||||
@@ -76,6 +78,33 @@ class TaigaController(private val client: TaigaClient) : TaigaApi {
|
|||||||
return created(ticket)
|
return created(ticket)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bulk-Lesen (D91-Nachtrag 10): eine Anfrage vom Editor, der Fächer läuft
|
||||||
|
im Client. Die Refs kommen MIT Werkbaum-Präfix (`US-`/`T-`) — es trägt
|
||||||
|
den Typ; geparst wird streng, Ungültiges ist ein 400 statt still
|
||||||
|
übersprungen (D59-Linie). Doppelte Refs werden vor dem Fächer
|
||||||
|
zusammengelegt. */
|
||||||
|
override fun taigaTickets(
|
||||||
|
xTaigaToken: String,
|
||||||
|
slug: String,
|
||||||
|
refs: String,
|
||||||
|
): ResponseEntity<Map<String, TaigaTicketDetail>> =
|
||||||
|
ResponseEntity.ok(
|
||||||
|
client.tickets(xTaigaToken, slug, parseBulkRefs(refs))
|
||||||
|
.mapValues { it.value.toApi() }
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun parseBulkRefs(refs: String): List<TaigaBulkRefData> {
|
||||||
|
val teile = refs.split(',').distinct()
|
||||||
|
if (teile.size > MAX_BULK_REFS) {
|
||||||
|
throw TaigaBadRequestException("Höchstens $MAX_BULK_REFS Refs je Anfrage")
|
||||||
|
}
|
||||||
|
return teile.map { teil ->
|
||||||
|
val m = BULK_REF.matchEntire(teil)
|
||||||
|
?: throw TaigaBadRequestException("Ungültige Ref: '$teil'")
|
||||||
|
TaigaBulkRefData(key = teil, task = m.groupValues[1] == "T", nr = m.groupValues[2].toLong())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Lesen (D91-Nachtrag 6): zwei Endpunkte statt eines mit Typ-Parameter —
|
/* Lesen (D91-Nachtrag 6): zwei Endpunkte statt eines mit Typ-Parameter —
|
||||||
das Präfix der Ref trägt den Typ, und Taiga hat getrennte
|
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
|
`by_ref`-Endpunkte. Die Abbildung des Status auf die Notation macht der
|
||||||
@@ -146,4 +175,10 @@ class TaigaController(private val client: TaigaClient) : TaigaApi {
|
|||||||
ResponseEntity.status(HttpStatus.CREATED).body(
|
ResponseEntity.status(HttpStatus.CREATED).body(
|
||||||
TaigaTicket(id = ticket.id, ref = ticket.ref, subject = ticket.subject)
|
TaigaTicket(id = ticket.id, ref = ticket.ref, subject = ticket.subject)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Höchstens so viele Refs je Bulk-Anfrage — mehr referenziert kein Plan. */
|
||||||
|
private const val MAX_BULK_REFS = 200
|
||||||
|
private val BULK_REF = Regex("(US|T)-(\\d{1,10})")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import java.net.URLEncoder
|
|||||||
import java.net.http.HttpClient
|
import java.net.http.HttpClient
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
import java.util.concurrent.Callable
|
||||||
|
import java.util.concurrent.ExecutionException
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.Semaphore
|
||||||
|
|
||||||
/** Keine Taiga-Instanz konfiguriert — der Proxy hat kein Ziel (503). */
|
/** Keine Taiga-Instanz konfiguriert — der Proxy hat kein Ziel (503). */
|
||||||
class TaigaNotConfiguredException :
|
class TaigaNotConfiguredException :
|
||||||
@@ -58,6 +62,17 @@ data class TaigaTicketDetailData(
|
|||||||
/** Eine Spalte des Projekt-Workflows (D91-Nachtrag 8). */
|
/** Eine Spalte des Projekt-Workflows (D91-Nachtrag 8). */
|
||||||
data class TaigaStatusData(val id: Long, val name: String, val closed: Boolean?)
|
data class TaigaStatusData(val id: Long, val name: String, val closed: Boolean?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Ref der Bulk-Abfrage (D91-Nachtrag 10): `key` ist die Werkbaum-Ref
|
||||||
|
* (`US-123`), unter der die Antwort zurueckgeht; `task` und `nr` sind das
|
||||||
|
* zerlegte Praefix — der Typ steht in der Ref, genau dafuer schreibt
|
||||||
|
* Werkbaum ihn (SPEC par. 11).
|
||||||
|
*/
|
||||||
|
data class TaigaBulkRefData(val key: String, val task: Boolean, val nr: Long)
|
||||||
|
|
||||||
|
/** Eine Anfrage, die schon der Proxy ablehnt (400) — kein Taiga-Fehler. */
|
||||||
|
class TaigaBadRequestException(message: String) : RuntimeException(message)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schmaler, benannter Client zur konfigurierten Taiga-Instanz (D91) — kein
|
* Schmaler, benannter Client zur konfigurierten Taiga-Instanz (D91) — kein
|
||||||
* Durchreich-Proxy: genau die vier Aufrufe, die die Ticket-Anlage braucht.
|
* Durchreich-Proxy: genau die vier Aufrufe, die die Ticket-Anlage braucht.
|
||||||
@@ -71,6 +86,11 @@ data class TaigaStatusData(val id: Long, val name: String, val closed: Boolean?)
|
|||||||
@Service
|
@Service
|
||||||
class TaigaClient(private val properties: TaigaProperties) {
|
class TaigaClient(private val properties: TaigaProperties) {
|
||||||
|
|
||||||
|
/* Der Faecher der Bulk-Abfrage: virtuelle Threads (praktisch kostenlos,
|
||||||
|
D76-Nachtrag 5), die Semaphore deckelt die GLEICHZEITIGEN Anfragen an
|
||||||
|
die fremde Instanz — Parallelitaet ja, Hammer nein. */
|
||||||
|
private val bulkPool = Executors.newVirtualThreadPerTaskExecutor()
|
||||||
|
|
||||||
private val rest: RestClient = RestClient.builder()
|
private val rest: RestClient = RestClient.builder()
|
||||||
.requestFactory(
|
.requestFactory(
|
||||||
JdkClientHttpRequestFactory(
|
JdkClientHttpRequestFactory(
|
||||||
@@ -124,8 +144,10 @@ class TaigaClient(private val properties: TaigaProperties) {
|
|||||||
* Aufrufer aus dem Präfix der Ref (`US-`/`T-`, SPEC §11) — hier steht
|
* Aufrufer aus dem Präfix der Ref (`US-`/`T-`, SPEC §11) — hier steht
|
||||||
* nur, wohin gefragt wird.
|
* nur, wohin gefragt wird.
|
||||||
*/
|
*/
|
||||||
fun ticket(token: String, slug: String, ref: Long, task: Boolean): TaigaTicketDetailData {
|
fun ticket(token: String, slug: String, ref: Long, task: Boolean): TaigaTicketDetailData =
|
||||||
val project = projectId(token, slug)
|
detailByRef(token, projectId(token, slug), ref, task)
|
||||||
|
|
||||||
|
private fun detailByRef(token: String, project: Long, ref: Long, task: Boolean): TaigaTicketDetailData {
|
||||||
val pfad = if (task) "/tasks/by_ref" else "/userstories/by_ref"
|
val pfad = if (task) "/tasks/by_ref" else "/userstories/by_ref"
|
||||||
val map = exchange {
|
val map = exchange {
|
||||||
rest.get().uri(url("$pfad?project=$project&ref=$ref"))
|
rest.get().uri(url("$pfad?project=$project&ref=$ref"))
|
||||||
@@ -135,6 +157,47 @@ class TaigaClient(private val properties: TaigaProperties) {
|
|||||||
return detail(map)
|
return detail(map)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Viele Tickets auf einmal (D91-Nachtrag 10): EIN Aufruf vom Browser, der
|
||||||
|
* Proxy fächert in `by_ref`-Einzelabfragen auf — parallel, mit
|
||||||
|
* **gedeckelter** Nebenläufigkeit (Höflichkeit gegenüber der fremden
|
||||||
|
* Instanz; Backend und Taiga sitzen beim selben Hoster, die Einzelabfrage
|
||||||
|
* kostet dort Millisekunden). Die Kosten skalieren mit den Refs im Plan,
|
||||||
|
* nie mit der Projektgröße — eine Projekt-Volliste holte bei Tausenden
|
||||||
|
* Tickets Megabytes, um fast alles wegzuwerfen.
|
||||||
|
*
|
||||||
|
* Eine Ref, die es nicht (mehr) gibt (Taiga-404), **fehlt still** in der
|
||||||
|
* Antwort — der Rest kommt trotzdem; jeder andere Fehler (401, Projekt
|
||||||
|
* unbekannt, Instanz weg) bricht die ganze Anfrage ab, denn er beträfe
|
||||||
|
* ohnehin jede einzelne Ref.
|
||||||
|
*/
|
||||||
|
fun tickets(token: String, slug: String, refs: List<TaigaBulkRefData>): Map<String, TaigaTicketDetailData> {
|
||||||
|
val project = projectId(token, slug)
|
||||||
|
val sem = Semaphore(BULK_CONCURRENCY)
|
||||||
|
val futures = refs.map { r ->
|
||||||
|
bulkPool.submit(Callable {
|
||||||
|
sem.acquire()
|
||||||
|
try {
|
||||||
|
r.key to detailByRef(token, project, r.nr, r.task)
|
||||||
|
} finally {
|
||||||
|
sem.release()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
val out = LinkedHashMap<String, TaigaTicketDetailData>()
|
||||||
|
for (f in futures) {
|
||||||
|
try {
|
||||||
|
val (key, wert) = f.get()
|
||||||
|
out[key] = wert
|
||||||
|
} catch (e: ExecutionException) {
|
||||||
|
val grund = e.cause
|
||||||
|
if (grund is TaigaUpstreamException && grund.status == 404) continue
|
||||||
|
throw grund ?: e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Spalten des Projekt-Workflows (D91-Nachtrag 8) — Taiga schreibt nach
|
* Die Spalten des Projekt-Workflows (D91-Nachtrag 8) — Taiga schreibt nach
|
||||||
* Status-**Id**, und die Namen sind je Projekt frei. Welche Spalte gemeint
|
* Status-**Id**, und die Namen sind je Projekt frei. Welche Spalte gemeint
|
||||||
@@ -253,6 +316,8 @@ class TaigaClient(private val properties: TaigaProperties) {
|
|||||||
?: throw TaigaUnavailableException("Unerwartete Taiga-Antwort: Feld '$key' fehlt")
|
?: throw TaigaUnavailableException("Unerwartete Taiga-Antwort: Feld '$key' fehlt")
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
/** Hoechstens so viele gleichzeitige Upstream-Anfragen je Bulk-Aufruf. */
|
||||||
|
private const val BULK_CONCURRENCY = 6
|
||||||
private val MAP = object : ParameterizedTypeReference<Map<String, Any?>>() {}
|
private val MAP = object : ParameterizedTypeReference<Map<String, Any?>>() {}
|
||||||
private val LIST = object : ParameterizedTypeReference<List<Map<String, Any?>>>() {}
|
private val LIST = object : ParameterizedTypeReference<List<Map<String, Any?>>>() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -606,6 +606,73 @@ paths:
|
|||||||
"503":
|
"503":
|
||||||
$ref: "#/components/responses/TaigaNotConfigured"
|
$ref: "#/components/responses/TaigaNotConfigured"
|
||||||
|
|
||||||
|
/taiga/tickets:
|
||||||
|
get:
|
||||||
|
tags: [Taiga]
|
||||||
|
operationId: taigaTickets
|
||||||
|
summary: Den Stand vieler Tickets auf einmal lesen (Proxy)
|
||||||
|
description: >
|
||||||
|
Bulk-Lesen fuer die Abweichungs-Marken im Diagramm (D91-Nachtrag 10):
|
||||||
|
Der Editor stellt EINE Anfrage mit allen Refs eines Dokuments, der
|
||||||
|
Proxy faechert sie in `by_ref`-Einzelabfragen auf - parallel mit
|
||||||
|
gedeckelter Nebenlaeufigkeit, denn Backend und Taiga-Instanz sitzen
|
||||||
|
nah beieinander, der Browser nicht. Die Kosten skalieren mit den
|
||||||
|
Refs im Plan, nie mit der Groesse des Taiga-Projekts (dort koennen
|
||||||
|
Tausende Tickets liegen - eine Projekt-Volliste waere die falsche
|
||||||
|
Bulk-Einheit). Das Praefix jeder Ref traegt den Typ
|
||||||
|
(`US-`/`T-`, SPEC par. 11). Eine Ref, die es im Projekt nicht
|
||||||
|
(mehr) gibt, fehlt still in der Antwort - die uebrigen kommen
|
||||||
|
trotzdem; jeder andere Fehler (401, Projekt unbekannt, Taiga weg)
|
||||||
|
gilt der ganzen Anfrage.
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TaigaToken"
|
||||||
|
- $ref: "#/components/parameters/TaigaSlug"
|
||||||
|
- name: refs
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
description: >
|
||||||
|
Kommagetrennte Werkbaum-Refs (`US-123,T-1234`), hoechstens 200 -
|
||||||
|
mehr referenziert kein Plan, und der Deckel haelt den Faecher
|
||||||
|
endlich. Ungueltige Eintraege sind ein 400, nicht still
|
||||||
|
uebersprungen.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 4000
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: >
|
||||||
|
Map Werkbaum-Ref (`US-123`) auf den Ticket-Stand. Fehlende
|
||||||
|
Schluessel heissen: dieses Ticket gibt es im Projekt nicht (mehr).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
$ref: "#/components/schemas/TaigaTicketDetail"
|
||||||
|
"400":
|
||||||
|
description: Refs-Liste ungueltig oder zu lang
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
"401":
|
||||||
|
description: Token fehlt oder ist abgelaufen
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
"404":
|
||||||
|
description: Das Projekt zum Slug gibt es nicht
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
"502":
|
||||||
|
$ref: "#/components/responses/TaigaUnavailable"
|
||||||
|
"503":
|
||||||
|
$ref: "#/components/responses/TaigaNotConfigured"
|
||||||
|
|
||||||
/taiga/userstories/{ref}/status:
|
/taiga/userstories/{ref}/status:
|
||||||
patch:
|
patch:
|
||||||
tags: [Taiga]
|
tags: [Taiga]
|
||||||
|
|||||||
@@ -154,6 +154,31 @@ class TaigaApiTest {
|
|||||||
result.responseBody!! shouldContain """{"id":12,"name":"In progress","closed":false}"""
|
result.responseBody!! shouldContain """{"id":12,"name":"In progress","closed":false}"""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `die Bulk-Abfrage liefert eine Map unter den Werkbaum-Refs`() {
|
||||||
|
val result = client.get()
|
||||||
|
.uri("/api/v1/taiga/tickets?slug=mi-kunde&refs=US-123,T-1234")
|
||||||
|
.header("X-Taiga-Token", "tok-abc123")
|
||||||
|
.exchange()
|
||||||
|
.returnResult(String::class.java)
|
||||||
|
result.status.value() shouldBe 200
|
||||||
|
result.responseBody!! shouldContain "\"US-123\""
|
||||||
|
result.responseBody!! shouldContain "\"T-1234\""
|
||||||
|
result.responseBody!! shouldContain "\"status\":\"In progress\""
|
||||||
|
result.responseBody!! shouldContain "\"status\":\"Done\""
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `eine ungueltige Ref in der Bulk-Liste ist ein 400, nicht still uebersprungen`() {
|
||||||
|
val result = client.get()
|
||||||
|
.uri("/api/v1/taiga/tickets?slug=mi-kunde&refs=US-123,kaputt")
|
||||||
|
.header("X-Taiga-Token", "tok-abc123")
|
||||||
|
.exchange()
|
||||||
|
.returnResult(String::class.java)
|
||||||
|
result.status.value() shouldBe 400
|
||||||
|
result.responseBody!! shouldContain "kaputt"
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `ein Status wird per Ref gesetzt und antwortet mit dem neuen Stand`() {
|
fun `ein Status wird per Ref gesetzt und antwortet mit dem neuen Stand`() {
|
||||||
val result = client.patch()
|
val result = client.patch()
|
||||||
|
|||||||
@@ -220,6 +220,49 @@ class TaigaClientTest {
|
|||||||
client().ticket("tok-abc123", "mi-kunde", 123, task = false).version shouldBe 7L
|
client().ticket("tok-abc123", "mi-kunde", 123, task = false).version shouldBe 7L
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Bulk-Lesen (D91-Nachtrag 10) ---- */
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tickets faechert je Ref auf und liefert die Map unter den Werkbaum-Refs`() {
|
||||||
|
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
|
||||||
|
routes["/api/v1/userstories/by_ref?project=7&ref=123"] = 200 to STORY_DETAIL
|
||||||
|
routes["/api/v1/tasks/by_ref?project=7&ref=1234"] = 200 to TASK_DETAIL
|
||||||
|
val map = client().tickets("tok-abc123", "mi-kunde", listOf(
|
||||||
|
TaigaBulkRefData("US-123", task = false, nr = 123),
|
||||||
|
TaigaBulkRefData("T-1234", task = true, nr = 1234),
|
||||||
|
))
|
||||||
|
|
||||||
|
map.keys shouldBe setOf("US-123", "T-1234")
|
||||||
|
map["US-123"]!!.status shouldBe "In progress"
|
||||||
|
map["T-1234"]!!.status shouldBe "Done"
|
||||||
|
/* Die Projekt-Aufloesung laeuft EINMAL, nicht je Ref. */
|
||||||
|
requests.count { it.path == "/api/v1/projects/by_slug" } shouldBe 1
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `eine Ref, die es nicht mehr gibt, fehlt still - die uebrigen kommen trotzdem`() {
|
||||||
|
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
|
||||||
|
routes["/api/v1/userstories/by_ref?project=7&ref=123"] = 200 to STORY_DETAIL
|
||||||
|
routes["/api/v1/userstories/by_ref?project=7&ref=999"] = 404 to NOT_FOUND
|
||||||
|
val map = client().tickets("tok-abc123", "mi-kunde", listOf(
|
||||||
|
TaigaBulkRefData("US-123", task = false, nr = 123),
|
||||||
|
TaigaBulkRefData("US-999", task = false, nr = 999),
|
||||||
|
))
|
||||||
|
|
||||||
|
map.keys shouldBe setOf("US-123")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ein abgelaufenes Token bricht die ganze Bulk-Anfrage ab`() {
|
||||||
|
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
|
||||||
|
routes["/api/v1/userstories/by_ref?project=7&ref=123"] = 401 to NOT_FOUND
|
||||||
|
val ex = shouldThrow<TaigaUpstreamException> {
|
||||||
|
client().tickets("tok-abc123", "mi-kunde",
|
||||||
|
listOf(TaigaBulkRefData("US-123", task = false, nr = 123)))
|
||||||
|
}
|
||||||
|
ex.status shouldBe 401
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `ohne konfigurierte Instanz gibt es kein Ziel`() {
|
fun `ohne konfigurierte Instanz gibt es kein Ziel`() {
|
||||||
val bare = TaigaClient(TaigaProperties(apiUrl = ""))
|
val bare = TaigaClient(TaigaProperties(apiUrl = ""))
|
||||||
@@ -315,7 +358,10 @@ class TaigaClientTest {
|
|||||||
body = ex.requestBody.readBytes().decodeToString(),
|
body = ex.requestBody.readBytes().decodeToString(),
|
||||||
)
|
)
|
||||||
requests += recorded!!
|
requests += recorded!!
|
||||||
val route = routes[ex.requestURI.path]
|
/* Query-genaue Route vor der Pfad-Route: Die Bulk-Abfrage
|
||||||
|
trifft denselben Pfad mit verschiedenen Refs. */
|
||||||
|
val route = routes[ex.requestURI.path + "?" + (ex.requestURI.query ?: "")]
|
||||||
|
?: routes[ex.requestURI.path]
|
||||||
val status = route?.first ?: responseStatus
|
val status = route?.first ?: responseStatus
|
||||||
val bytes = (route?.second ?: responseBody).encodeToByteArray()
|
val bytes = (route?.second ?: responseBody).encodeToByteArray()
|
||||||
ex.responseHeaders.set("Content-Type", "application/json")
|
ex.responseHeaders.set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ reverse.
|
|||||||
|
|
||||||
## 2026-08-28
|
## 2026-08-28
|
||||||
|
|
||||||
|
- One bulk request reads the status of all referenced tickets: the proxy fans the refs out right next to the Taiga instance, so the cost scales with the refs in the plan — not with a project that holds thousands of tickets
|
||||||
|
- With a Taiga login, diverging tickets show up in the diagram: a ref whose ticket status no longer matches the node's box turns amber — as its own little badge where the ref is the node id and thus not part of the title
|
||||||
|
- The bulk result pre-fills the node window's ticket cache, so hovering a ticket node usually needs no request of its own any more
|
||||||
|
- The divergence mark is session knowledge and stays out of the SVG export and of print — what you share is the plan, not your fetch state
|
||||||
- The create buttons in the node window are recut: one "create story" button always opens the dialog with project choice and sub-package checkboxes — unchecking covers the story-only case
|
- The create buttons in the node window are recut: one "create story" button always opens the dialog with project choice and sub-package checkboxes — unchecking covers the story-only case
|
||||||
- A "create task" button appears where an ancestor node already carries a story: the task lands in that story without any dialog — project and story follow from the tree, only an error gets a surface
|
- A "create task" button appears where an ancestor node already carries a story: the task lands in that story without any dialog — project and story follow from the tree, only an error gets a surface
|
||||||
|
|
||||||
|
|||||||
@@ -8386,3 +8386,88 @@ benannte Test.
|
|||||||
Seite nicht (Klicks schon: das DOM ist geteilt, die JS-Objekte nicht). Eine
|
Seite nicht (Klicks schon: das DOM ist geteilt, die JS-Objekte nicht). Eine
|
||||||
Rückfrage lässt sich dort also nicht wegstubben; Aufräumen, das durch eine
|
Rückfrage lässt sich dort also nicht wegstubben; Aufräumen, das durch eine
|
||||||
Rückfrage führt, geht stattdessen über die Ablage selbst (D83-Schema).
|
Rückfrage führt, geht stattdessen über die Ablage selbst (D83-Schema).
|
||||||
|
|
||||||
|
**Nachtrag 10 — Bulk-Abfrage und Abweichungs-Marke: das Diagramm zeigt, wo
|
||||||
|
Ticket und Plan auseinanderlaufen (2026-08-28).** Nutzerwunsch in zwei
|
||||||
|
Schritten: erst die Frage nach einer Bulk-Abfrage („Einzelabfrage wäre
|
||||||
|
wahrscheinlich zu langsam"), dann — auf den Befund, dass die Taiga-Projekte
|
||||||
|
**Tausende** Stories und Tasks haben — die nach einem Vorfilter. Die
|
||||||
|
Antwort auf beide ist dieselbe: **Der Filter sind die Refs im Plan, und
|
||||||
|
angewandt wird er im Proxy.**
|
||||||
|
|
||||||
|
- **`GET /taiga/tickets?slug=…&refs=US-1,T-2,…`** — EIN Aufruf vom Browser,
|
||||||
|
der Proxy fächert in `by_ref`-Einzelabfragen auf: **parallel** (virtuelle
|
||||||
|
Threads, D76-Nachtrag 5) mit **gedeckelter Nebenläufigkeit** (Semaphore,
|
||||||
|
6 gleichzeitig — Höflichkeit gegenüber der fremden Instanz). Die teure
|
||||||
|
Strecke Browser→Backend (~130 ms, gemessen) fällt einmal an; Backend und
|
||||||
|
Taiga-API sitzen beim selben Hoster, dort kosten die Einzelabfragen
|
||||||
|
Millisekunden. Die Kosten skalieren mit den Refs im Plan (Dutzende), nie
|
||||||
|
mit dem Projekt (Tausende) — die zuerst erwogene **Projekt-Volliste ist
|
||||||
|
damit verworfen**: Sie holte Megabytes, um fast alles wegzuwerfen, und
|
||||||
|
ließe Taiga je Anfrage eine große Liste rechnen.
|
||||||
|
- **Ein Endpunkt für beide Typen:** Das Präfix jeder Ref trägt den Typ
|
||||||
|
(`US-`/`T-`, Nachtrag 2) — genau die Auflösung, für die Werkbaum es
|
||||||
|
schreibt. Antwort ist eine Map Ref → Stand (dieselben Felder wie der
|
||||||
|
Einzel-Abruf, samt `version` — der Bulk füllt den Ticket-Cache des
|
||||||
|
Knoten-Fensters vor, das Fenster braucht dann meist keinen Abruf mehr).
|
||||||
|
- **Fehler-Semantik:** Eine Ref, die es nicht (mehr) gibt, **fehlt still**
|
||||||
|
in der Antwort — die übrigen kommen trotzdem; 401, unbekanntes Projekt
|
||||||
|
oder eine tote Instanz brechen die ganze Anfrage ab (sie beträfen jede
|
||||||
|
Ref). Ungültige Refs in der Liste sind ein **400, nicht still
|
||||||
|
übersprungen** (D59); doppelte werden vor dem Fächer zusammengelegt, und
|
||||||
|
bei **200 Refs** ist benannt Schluss (Client schneidet, Server lehnt ab).
|
||||||
|
- **Die Marke:** Eine Ref, deren Ticket-Status abgebildet ist und nicht zur
|
||||||
|
Statusbox passt, färbt sich **warnfarben**. Beim Bauen fiel die Lücke des
|
||||||
|
Vorschlags auf: `- [ ] Login #US-123` macht die Ref zur **Knoten-ID**
|
||||||
|
(§1, erstes `#`-Token) — sie steht gar nicht im Label, es gibt nichts zu
|
||||||
|
färben. Für diesen Fall hängt der **Renderer** ein kleines nachgestelltes
|
||||||
|
Badge mit der Ref an (die D40-Bauform der ”-Marke: vor dem Messen, die
|
||||||
|
Geometrie stimmt); sichtbare Refs (eigene ID auf der Zeile, D60-Knoten)
|
||||||
|
färben sich selbst. Ein unabgebildeter Spaltenname markiert nichts —
|
||||||
|
dieselbe Regel wie im Fenster.
|
||||||
|
- **Wann:** einmal je Projekt und Sitzung, angestoßen vom Neubau, von
|
||||||
|
`GET /info` (die Antwort kommt nach dem ersten Neubau) und von jeder
|
||||||
|
Anmeldung; ohne Anmeldung nie (die Nachtrag-6-Linie). Das Markieren aus
|
||||||
|
dem Cache ist kostenlos und läuft je Neubau mit; kommt der Bulk an,
|
||||||
|
rendert sein Callback — **außer** ein Knoten-Fenster ist offen (der Neubau
|
||||||
|
schlösse es): dann nur die Klassen-Marken, Badges kommen mit dem nächsten
|
||||||
|
Neubau. Einzel-Abrufe (↻, Schreiben) ziehen die Marken mit nach; nach
|
||||||
|
„nach Taiga schreiben" räumt `markTicketDiffs` erst ab und setzt neu —
|
||||||
|
sonst stünde die Marke auf einem Ticket, das wieder einig ist.
|
||||||
|
- **Nicht im Export, nicht im Druck:** Die Marke hängt an der Sitzung
|
||||||
|
(Anmeldung, Abrufzeitpunkt) — exportiert wird der Plan, nicht der
|
||||||
|
persönliche Abrufstand (dieselbe Linie wie der gelbe Kranz, D28). Der
|
||||||
|
Export liest die Label-Farbe vom **Knoten**, nicht von der Spanne
|
||||||
|
(nachgemessen), das Badge ist per `excludeSel` ausgenommen, der Druck
|
||||||
|
blendet es aus. Und im `aria-label` steht die Abweichung nicht — sie ist
|
||||||
|
asynchrones Sitzungswissen, der Screenreader-Weg ist das Knoten-Fenster;
|
||||||
|
benannt als Grenze, nicht übersehen.
|
||||||
|
- **Blass bleibt blass:** Auf einem vom Pfad zurückgetretenen Knoten dimmt
|
||||||
|
die Marke mit (die frontend/CLAUDE.md-Prüffrage ist gestellt): Anders als
|
||||||
|
bei `fresh`/`focusmark` ist die Aussage hier an einem nicht gebrauchten
|
||||||
|
Knoten auch weniger dringend, und erledigte Knoten treten seit D46 ohnehin
|
||||||
|
nie zurück.
|
||||||
|
- **Messnotiz für später:** Ob Taigas Listen-Endpunkte eine **Ref-Liste**
|
||||||
|
als Filter nehmen (`?project=…&refs=…`), ist nicht gemessen — die API-Doku
|
||||||
|
ist dort dünn. Falls ja, tauscht der Proxy sein Inneres (ein Aufruf statt
|
||||||
|
Fächer), ohne dass sich am Endpunkt oder im Editor etwas ändert.
|
||||||
|
|
||||||
|
**Nachgemessen** Ende-zu-Ende (lokales Backend + Taiga-Stub): Die
|
||||||
|
Bulk-Antwort trägt beide Refs samt Status und `version` (curl); im Browser
|
||||||
|
bekommt der Ref-als-ID-Knoten mit Abweichung das Badge in `--warn`
|
||||||
|
(`rgb(180,83,9)`), der einige Knoten nichts, der Label-Ref färbt sich ohne
|
||||||
|
Badge; der Stub-Mitschnitt zeigt je Bulk **einmal** `by_slug` und je
|
||||||
|
eindeutiger Ref eine `by_ref`-Abfrage (Dedupe greift); das Knoten-Fenster
|
||||||
|
liest danach aus dem Cache (kein weiterer Abruf); im exportierten SVG steht
|
||||||
|
der Label-Ref genau einmal, in Knotenfarbe, ohne Badge und ohne Bernstein.
|
||||||
|
Backend-`check` grün (5 neue Tests: Fächer mit Map, 404 fällt still,
|
||||||
|
401 bricht ab, Bulk-E2E, ungültige Ref → 400); Frontend 617 Tests (15
|
||||||
|
neue). Gegenproben per Mutation: 404-Überspringen entfernt → genau der
|
||||||
|
danach benannte Test fällt (1 von 21); Dedupe entfernt → genau der eine.
|
||||||
|
|
||||||
|
**Werkzeug-Notizen:** Der D82-Abschieds-Flush gewinnt gegen ein
|
||||||
|
`localStorage.setItem` + `location.reload()` — wer den Speicher unter einer
|
||||||
|
lebenden Seite umschreibt, bekommt beim Reload deren Gedächtnis zurück;
|
||||||
|
wiederhergestellt wird ein Dokument als gewöhnliche Änderung über das
|
||||||
|
Textfeld. Und der Debug-Reset hängt am `confirm`, das die isolierte Welt
|
||||||
|
des Prüf-Panes nicht stubben kann (Nachtrag 9) — er ist dort wirkungslos.
|
||||||
|
|||||||
@@ -529,6 +529,22 @@ es einen gibt, den **Zuständigen**.
|
|||||||
|
|
||||||
Siehe D91-Nachträge 6, 7 und 8.
|
Siehe D91-Nachträge 6, 7 und 8.
|
||||||
|
|
||||||
|
**Abweichungs-Marke im Diagramm.** Mit Anmeldung an der Instanz holt **eine
|
||||||
|
Bulk-Anfrage je Projekt und Sitzung** den Stand aller referenzierten Tickets
|
||||||
|
(der Proxy fächert sie serverseitig in Einzelabfragen auf — die Kosten
|
||||||
|
skalieren mit den Refs im Plan, nie mit der Größe des Taiga-Projekts). Eine
|
||||||
|
Ref, deren Ticket-Status **abgebildet** ist und nicht zur Statusbox des
|
||||||
|
Knotens passt, färbt sich **warnfarben** — steht sie im Label, die Ref
|
||||||
|
selbst; ist sie (nur) die Knoten-ID (§1: das erste `#`-Token), erscheint sie
|
||||||
|
als kleines nachgestelltes Badge hinter dem Titel (die Bauform der ”-Marke).
|
||||||
|
Ein unabgebildeter Spaltenname markiert nichts — er sagt nichts über den
|
||||||
|
Plan. Die Einzelheiten samt der beiden Aktionen stehen wie gehabt im
|
||||||
|
Knoten-Fenster; ein Ticket, das es nicht (mehr) gibt, markiert nichts, und
|
||||||
|
ein Fehler der Hintergrund-Abfrage bleibt still. Ohne Anmeldung wird nichts
|
||||||
|
geholt. Die Marke hängt an der Sitzung (Anmeldung, Abrufzeitpunkt) und
|
||||||
|
erscheint deshalb **weder im Grafikexport noch im Druck** — exportiert wird
|
||||||
|
der Plan, nicht der persönliche Abrufstand. Siehe D91-Nachtrag 10.
|
||||||
|
|
||||||
**Die Knotenfarbe zeigt den effektiven Status (§4)**, nicht den intrinsischen —
|
**Die Knotenfarbe zeigt den effektiven Status (§4)**, nicht den intrinsischen —
|
||||||
das Diagramm beantwortet „wie weit ist das wirklich?“. Wo der eigene Status
|
das Diagramm beantwortet „wie weit ist das wirklich?“. Wo der eigene Status
|
||||||
**weiter** ist als der effektive (der Knoten wird von Abhängigkeiten
|
**weiter** ist als der effektive (der Knoten wird von Abhängigkeiten
|
||||||
|
|||||||
@@ -205,6 +205,9 @@
|
|||||||
- [^] #trk.resolve.read: Read title, link and status (S)
|
- [^] #trk.resolve.read: Read title, link and status (S)
|
||||||
- [/] #trk.resolve.map: Map the workflow onto the states (S)
|
- [/] #trk.resolve.map: Map the workflow onto the states (S)
|
||||||
- [^] #trk.write: Write the status back (M) :#trk.resolve
|
- [^] #trk.write: Write the status back (M) :#trk.resolve
|
||||||
|
- [x] #trk.bulk: Diverging tickets show up in the diagram (M) :#trk.resolve.read
|
||||||
|
- [x] #trk.bulk.proxy: One request fans out into by_ref queries server-side (S)
|
||||||
|
- [x] #trk.bulk.mark: A diverging ref turns amber on its node (S)
|
||||||
- [^] #trk.create: Create tickets from nodes (XL)
|
- [^] #trk.create: Create tickets from nodes (XL)
|
||||||
- [^] #trk.create.proxy: Backend proxy with named endpoints (M)
|
- [^] #trk.create.proxy: Backend proxy with named endpoints (M)
|
||||||
- [^] #trk.create.login: Log in to Taiga, token stays in the browser (S) :#trk.create.proxy
|
- [^] #trk.create.login: Log in to Taiga, token stays in the browser (S) :#trk.create.proxy
|
||||||
@@ -1158,6 +1161,27 @@
|
|||||||
The default five are mapped, an unknown column name stays unmapped and is
|
The default five are mapped, an unknown column name stays unmapped and is
|
||||||
shown as plain text; making the mapping configurable is still open.
|
shown as plain text; making the mapping configurable is still open.
|
||||||
|
|
||||||
|
#trk.bulk
|
||||||
|
With a Taiga login, one bulk request per project and session reads the
|
||||||
|
status of every referenced ticket, and a ref whose ticket no longer
|
||||||
|
matches the node's status box turns amber. Details and both sync actions
|
||||||
|
stay in the node window; the mark is session knowledge and stays out of
|
||||||
|
export and print.
|
||||||
|
|
||||||
|
#trk.bulk.proxy
|
||||||
|
GET /taiga/tickets takes the plan's refs and fans them out into by_ref
|
||||||
|
queries next to the Taiga instance, in parallel with capped concurrency.
|
||||||
|
The cost scales with the refs in the plan, never with a project holding
|
||||||
|
thousands of tickets - the refs themselves are the pre-filter. A ref that
|
||||||
|
no longer exists is silently absent; the bulk also pre-fills the node
|
||||||
|
window's ticket cache.
|
||||||
|
|
||||||
|
#trk.bulk.mark
|
||||||
|
A visible ref colours itself; where the ref is the node id and thus not
|
||||||
|
part of the title, the renderer appends it as a small badge behind the
|
||||||
|
label - the same construction as the description mark. An unmapped column
|
||||||
|
name marks nothing: it says nothing about the plan.
|
||||||
|
|
||||||
#trk.write
|
#trk.write
|
||||||
The other direction. Neither side wins by itself: a difference between the
|
The other direction. Neither side wins by itself: a difference between the
|
||||||
ticket and the status box is marked, and both directions are offered as
|
ticket and the status box is marked, and both directions are offered as
|
||||||
|
|||||||
@@ -684,6 +684,18 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
|
|||||||
läuft schon beim Aufbau — sonst temporale Todeszone). Der
|
läuft schon beim Aufbau — sonst temporale Todeszone). Der
|
||||||
`pointerdown`-Wächter lässt `.tabmodal-overlay` durch: Der Anmelde-Dialog
|
`pointerdown`-Wächter lässt `.tabmodal-overlay` durch: Der Anmelde-Dialog
|
||||||
gehört zu einer Aktion AUS dem Fenster und darf es nicht zumachen.
|
gehört zu einer Aktion AUS dem Fenster und darf es nicht zumachen.
|
||||||
|
- **Abweichungs-Marken im Diagramm (D91-Nachtrag 10):** `collectTicketRefs`,
|
||||||
|
`bulkPath` (dedupliziert, Deckel 200), `ticketDiverges` und
|
||||||
|
`refVisibleInLabel` liegen headless in taiga.js. app.js: `scheduleTaigaBulk`
|
||||||
|
holt je Slug EINMAL je Sitzung (nur mit Sitzung; Hintergrund-Fehler still),
|
||||||
|
füllt den `taigaTickets`-Cache vor und rendert — außer ein Knoten-Fenster
|
||||||
|
ist offen (der Neubau schlösse es), dann nur `markTicketDiffs`
|
||||||
|
(Klassen-Marken, räumt erst ab). Wo die Ref die **Knoten-ID** ist, steht
|
||||||
|
sie nicht im Label — der **Renderer** hängt dann ein `tref-badge` an
|
||||||
|
(`tdiffRefs`-Option, die D40-Bauform der ”-Marke: vor dem Messen, die
|
||||||
|
Geometrie stimmt). Nicht im Export (`excludeSel` + Label-Farbe kommt vom
|
||||||
|
Knoten), nicht im Druck, nicht im `aria-label` (benannte Grenze: der
|
||||||
|
Screenreader-Weg ist das Knoten-Fenster).
|
||||||
- **Status zurückschreiben (D91-Nachtrag 7/8):** Weicht der Ticket-Status von
|
- **Status zurückschreiben (D91-Nachtrag 7/8):** Weicht der Ticket-Status von
|
||||||
der Statusbox ab, zeigt `paintDiff()` beides und bietet **zwei** Knöpfe —
|
der Statusbox ab, zeigt `paintDiff()` beides und bietet **zwei** Knöpfe —
|
||||||
von selbst geschieht in keine Richtung etwas. `pushStatus()` sucht die
|
von selbst geschieht in keine Richtung etwas. `pushStatus()` sucht die
|
||||||
|
|||||||
+97
-3
@@ -1,7 +1,7 @@
|
|||||||
import './style.css';
|
import './style.css';
|
||||||
import { parse, setFoldMark, setStatusBox, expandShortIds, shortIdClosed } from './parser.js';
|
import { parse, setFoldMark, setStatusBox, expandShortIds, shortIdClosed } from './parser.js';
|
||||||
import { computeCheapPlan, overloadedAssignee, assigneeLoads, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, presetFoldSet, personFoldSet, allTags, lineTargets, taigaSlugs } from './model.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, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath, storyAncestor } from './taiga.js';
|
import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath, storyAncestor, collectTicketRefs, bulkPath, ticketDiverges, refVisibleInLabel } from './taiga.js';
|
||||||
import { esc, renderTreeHtml, TIP_RULE } from './render.js';
|
import { esc, renderTreeHtml, TIP_RULE } from './render.js';
|
||||||
import { formatWarning, warningText } from './warnings.js';
|
import { formatWarning, warningText } from './warnings.js';
|
||||||
import * as live from './live.js';
|
import * as live from './live.js';
|
||||||
@@ -322,7 +322,8 @@ function render(){
|
|||||||
freshSet, collapsedSet,
|
freshSet, collapsedSet,
|
||||||
effStatus: effectiveStatus(roots),
|
effStatus: effectiveStatus(roots),
|
||||||
overloadTag: overload ? overload.tag : null,
|
overloadTag: overload ? overload.tag : null,
|
||||||
lensTag: lens ? lens.tag : null});
|
lensTag: lens ? lens.tag : null,
|
||||||
|
tdiffRefs: ticketDiffBadgeMap(parsed.roots)});
|
||||||
out.innerHTML = r.html;
|
out.innerHTML = r.html;
|
||||||
warnings = warnings.concat(r.warnings);
|
warnings = warnings.concat(r.warnings);
|
||||||
/* Personen-Leiste (D87): Belastung je Person aus der offenen Pfad-Arbeit
|
/* Personen-Leiste (D87): Belastung je Person aus der offenen Pfad-Arbeit
|
||||||
@@ -365,6 +366,12 @@ function render(){
|
|||||||
updateFreshBtn(); /* Zähler folgt der gerade gerenderten Menge (D28) */
|
updateFreshBtn(); /* Zähler folgt der gerade gerenderten Menge (D28) */
|
||||||
updateLeanBtn(); /* Stationen des Pfads haben sich geändert (D47) */
|
updateLeanBtn(); /* Stationen des Pfads haben sich geändert (D47) */
|
||||||
updateFoldBtn(); /* Icon + Tooltip = nächster Schritt des Durchschalters (D75) */
|
updateFoldBtn(); /* Icon + Tooltip = nächster Schritt des Durchschalters (D75) */
|
||||||
|
/* Abweichungs-Marken (D91-Nachtrag 10): aus dem Cache — kostenlos; die
|
||||||
|
Bulk-Abfrage selbst läuft je Slug nur einmal je Sitzung. Auf den
|
||||||
|
UNGEFILTERTEN roots, denn die Slug-Vererbung läuft über den ganzen
|
||||||
|
Baum; was nicht im DOM steht, überspringt das Markieren selbst. */
|
||||||
|
markTicketDiffs(parsed.roots);
|
||||||
|
scheduleTaigaBulk(parsed.roots);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Personen-Leiste (SPEC §9, D87) ----------
|
/* ---------- Personen-Leiste (SPEC §9, D87) ----------
|
||||||
@@ -892,7 +899,7 @@ function diagramToSvg(){
|
|||||||
const deco = cs.textDecorationLine.includes('line-through') ? ' text-decoration="line-through"' : '';
|
const deco = cs.textDecorationLine.includes('line-through') ? ' text-decoration="line-through"' : '';
|
||||||
/* Seit Knoten umbrechen (D64), belegt ein Label mehrere Zeilenboxen — ein
|
/* Seit Knoten umbrechen (D64), belegt ein Label mehrere Zeilenboxen — ein
|
||||||
SVG-<text> bricht nicht von selbst; je gemessene Zeile ein Element. */
|
SVG-<text> bricht nicht von selbst; je gemessene Zeile ein Element. */
|
||||||
for(const ln of labelLines(node, '.size,.tags,.ext,.risk,.ownst,.desc-mark' + stripFold)){
|
for(const ln of labelLines(node, '.size,.tags,.ext,.risk,.ownst,.desc-mark,.tref-badge' + stripFold)){
|
||||||
const cx = (ln.left + ln.right) / 2 + ox, cy = (ln.top + ln.bottom) / 2 + oy;
|
const cx = (ln.left + ln.right) / 2 + ox, cy = (ln.top + ln.bottom) / 2 + oy;
|
||||||
parts.push(`<text x="${cx.toFixed(1)}" y="${(cy+5).toFixed(1)}" text-anchor="middle" fill="${cs.color}" font-size="14" font-weight="${cs.fontWeight}"${deco}>${esc(ln.text)}</text>`);
|
parts.push(`<text x="${cx.toFixed(1)}" y="${(cy+5).toFixed(1)}" text-anchor="middle" fill="${cs.color}" font-size="14" font-weight="${cs.fontWeight}"${deco}>${esc(ln.text)}</text>`);
|
||||||
}
|
}
|
||||||
@@ -1742,6 +1749,10 @@ function storeTaigaSession(s){
|
|||||||
if(s) localStorage.setItem(LS_TAIGA, JSON.stringify(s));
|
if(s) localStorage.setItem(LS_TAIGA, JSON.stringify(s));
|
||||||
else localStorage.removeItem(LS_TAIGA);
|
else localStorage.removeItem(LS_TAIGA);
|
||||||
}catch(_){}
|
}catch(_){}
|
||||||
|
/* Frisch angemeldet: die Abweichungs-Marken holen (D91-Nachtrag 10) —
|
||||||
|
sonst warteten sie auf den naechsten Tastendruck. Deckt jeden
|
||||||
|
Login-Weg ab, weil alle hier durchlaufen. */
|
||||||
|
if(s) scheduleTaigaBulk(parse(src.value).roots);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Welches Backend, und kann es Taiga? Dieselbe Basis-Herleitung wie beim
|
/* Welches Backend, und kann es Taiga? Dieselbe Basis-Herleitung wie beim
|
||||||
@@ -1767,6 +1778,9 @@ async function refreshTaiga(){
|
|||||||
}
|
}
|
||||||
const info = await taigaProbeCache.get(basis);
|
const info = await taigaProbeCache.get(basis);
|
||||||
if(taigaBase === basis){ taigaOn = info.on; taigaWeb = info.web; }
|
if(taigaBase === basis){ taigaOn = info.on; taigaWeb = info.web; }
|
||||||
|
/* Die Antwort kommt NACH dem ersten Neubau — die Bulk-Abfrage (D91-N10)
|
||||||
|
anstoßen, sonst wartete sie auf den nächsten Tastendruck. */
|
||||||
|
if(taigaOn) scheduleTaigaBulk(parse(src.value).roots);
|
||||||
}
|
}
|
||||||
|
|
||||||
function taigaFetch(path, options, token){
|
function taigaFetch(path, options, token){
|
||||||
@@ -2020,6 +2034,7 @@ async function pushStatus(key, slug, ref, name, version){
|
|||||||
taigaTickets.set(key, {kind: 'err', msg: taigaErrText(err)});
|
taigaTickets.set(key, {kind: 'err', msg: taigaErrText(err)});
|
||||||
}
|
}
|
||||||
repaintTicket(key);
|
repaintTicket(key);
|
||||||
|
markTicketDiffs(parse(src.value).roots); /* die Marke folgt dem geschriebenen Stand */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Die Spalten je Projekt und Typ — einmal je Sitzung, wie der Ticket-Stand
|
/* Die Spalten je Projekt und Typ — einmal je Sitzung, wie der Ticket-Stand
|
||||||
@@ -2061,6 +2076,7 @@ async function loadTicket(key, slug, ref, interactive){
|
|||||||
taigaTickets.set(key, {kind: 'err', msg: taigaErrText(err)});
|
taigaTickets.set(key, {kind: 'err', msg: taigaErrText(err)});
|
||||||
}
|
}
|
||||||
repaintTicket(key);
|
repaintTicket(key);
|
||||||
|
markTicketDiffs(parse(src.value).roots); /* die Marke folgt dem frischen Stand */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Gemalt wird nur, wenn genau dieses Ticket noch im offenen Fenster steht —
|
/* Gemalt wird nur, wenn genau dieses Ticket noch im offenen Fenster steht —
|
||||||
@@ -2072,6 +2088,84 @@ function repaintTicket(key){
|
|||||||
if(tipNode) placeNodeTip(tipNode);
|
if(tipNode) placeNodeTip(tipNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Abweichungs-Marken im Diagramm (D91-Nachtrag 10) ----------
|
||||||
|
Mit Anmeldung holt EINE Bulk-Anfrage je Projekt und Sitzung den Stand
|
||||||
|
aller referenzierten Tickets; eine Ref, deren Ticket-Status nicht zur
|
||||||
|
Statusbox des Knotens passt, färbt sich warnfarben (`tref-diff`) — die
|
||||||
|
Einzelheiten samt der zwei Knöpfe stehen wie gehabt im Knoten-Fenster.
|
||||||
|
Der Fächer läuft im Proxy: Die Kosten skalieren mit den Refs im Plan,
|
||||||
|
nie mit der Projektgröße. Ohne Anmeldung wird NICHTS geholt (die
|
||||||
|
D91-Nachtrag-6-Linie), und ein Hintergrund-Fehler bleibt still — kein
|
||||||
|
Dialog, keine Warnung; das Markieren aus dem Cache ist dagegen
|
||||||
|
kostenlos und läuft bei jedem Neubau mit. */
|
||||||
|
const taigaBulkDone = new Set(); /* Slugs, die diese Sitzung schon geholt sind */
|
||||||
|
|
||||||
|
function scheduleTaigaBulk(roots){
|
||||||
|
if(!taigaOn) return;
|
||||||
|
const session = taigaSession();
|
||||||
|
if(!session || !session.token) return;
|
||||||
|
for(const [slug, eintraege] of collectTicketRefs(roots, taigaSlugs(roots))){
|
||||||
|
if(taigaBulkDone.has(slug)) continue;
|
||||||
|
taigaBulkDone.add(slug);
|
||||||
|
const pfad = bulkPath(slug, eintraege.map(e => e.ref));
|
||||||
|
if(!pfad) continue;
|
||||||
|
taigaFetch(pfad, null, session.token).then(map => {
|
||||||
|
for(const [ref, data] of Object.entries(map || {})){
|
||||||
|
/* Ein schon einzeln geholtes Ticket bleibt stehen — es ist nie
|
||||||
|
älter als der Bulk (der läuft einmal, ganz am Anfang). */
|
||||||
|
const key = slug + '/' + ref;
|
||||||
|
if(!taigaTickets.has(key)) taigaTickets.set(key, {kind: 'ok', data});
|
||||||
|
}
|
||||||
|
/* Badges an unsichtbaren Refs brauchen den Neubau (Renderer, oben);
|
||||||
|
ein offenes Knoten-Fenster würde der aber schließen — dann nur die
|
||||||
|
Klassen-Marken, das Badge kommt mit dem nächsten Neubau. */
|
||||||
|
if(tipNode) markTicketDiffs(parse(src.value).roots);
|
||||||
|
else render();
|
||||||
|
}).catch(err => {
|
||||||
|
if(err && err.status === 401) storeTaigaSession(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Welche Knoten brauchen das Abweichungs-BADGE? Nur die, deren Ref nicht
|
||||||
|
im Label steht (sie ist die Knoten-ID, §1 — erstes `#`-Token) — sichtbare
|
||||||
|
Refs färben sich selbst (markTicketDiffs). Entschieden aus dem Cache zum
|
||||||
|
Zeitpunkt des Neubaus; kommt der Bulk später an, rendert sein Callback. */
|
||||||
|
function ticketDiffBadgeMap(roots){
|
||||||
|
const map = new Map();
|
||||||
|
if(!taigaOn) return map;
|
||||||
|
for(const [slug, eintraege] of collectTicketRefs(roots, taigaSlugs(roots))){
|
||||||
|
for(const e of eintraege){
|
||||||
|
const st = taigaTickets.get(slug + '/' + e.ref);
|
||||||
|
if(st && st.kind === 'ok' && ticketDiverges(e.node, st.data.status)
|
||||||
|
&& !refVisibleInLabel(e.node, e.ref)){
|
||||||
|
map.set(e.node.line, e.ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Marken aus dem Cache an die `tref`-Spannen legen — reine Anzeige, je
|
||||||
|
Neubau neu (das DOM ist frisch). Eingeklappte Knoten stehen nicht im DOM
|
||||||
|
und bleiben unmarkiert; ihr Ticket-Stand liegt trotzdem im Cache. */
|
||||||
|
function markTicketDiffs(roots){
|
||||||
|
/* Erst räumen, dann setzen — nach „nach Taiga schreiben" stimmen Ticket
|
||||||
|
und Box wieder überein, ohne dass der Baum neu gebaut wurde. */
|
||||||
|
for(const alt of out.querySelectorAll('.tref-diff')) alt.classList.remove('tref-diff');
|
||||||
|
for(const [slug, eintraege] of collectTicketRefs(roots, taigaSlugs(roots))){
|
||||||
|
for(const e of eintraege){
|
||||||
|
const st = taigaTickets.get(slug + '/' + e.ref);
|
||||||
|
if(!st || st.kind !== 'ok' || !ticketDiverges(e.node, st.data.status)) continue;
|
||||||
|
const el = out.querySelector('.node[data-line="' + e.node.line + '"]');
|
||||||
|
if(!el) continue;
|
||||||
|
for(const span of el.querySelectorAll('.tref')){
|
||||||
|
if(span.textContent === '#' + e.ref) span.classList.add('tref-diff');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Strg+Klick (macOS auch Cmd) auf einen Knoten mit Ticket-Referenz öffnet
|
/* Strg+Klick (macOS auch Cmd) auf einen Knoten mit Ticket-Referenz öffnet
|
||||||
das Ticket im Taiga-Frontend (D91-Nachtrag 5) — dieselbe Geste wie im
|
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
|
Text. Der einfache Klick bleibt der Link (§6), Alt der Sprung (D25); ohne
|
||||||
|
|||||||
@@ -290,6 +290,16 @@ function nodeHtml(n, extra, opts, fold){
|
|||||||
Notation. Nicht im Export: Der Text selbst kann dort nicht
|
Notation. Nicht im Export: Der Text selbst kann dort nicht
|
||||||
erscheinen, eine Marke ohne Ziel wäre Rauschen. */
|
erscheinen, eine Marke ohne Ziel wäre Rauschen. */
|
||||||
(n.desc ? '<span class="desc-mark" aria-hidden="true">”</span>' : '') +
|
(n.desc ? '<span class="desc-mark" aria-hidden="true">”</span>' : '') +
|
||||||
|
/* Abweichungs-Badge (D91-Nachtrag 10): Wo die Ref die
|
||||||
|
KNOTEN-ID ist, steht sie nicht im Label — die Marke
|
||||||
|
braucht dann einen sichtbaren Träger. Welche Zeilen eines
|
||||||
|
brauchen, entscheidet app.js aus dem Ticket-Cache; hier
|
||||||
|
wird nur angehängt (Geometrie stimmt, weil es VOR dem
|
||||||
|
Messen geschieht — die D40-Bauform der ”-Marke). Nicht im
|
||||||
|
Export (Sitzungswissen): dort per excludeSel ausgenommen. */
|
||||||
|
(opts.tdiffRefs && opts.tdiffRefs.has(n.line)
|
||||||
|
? `<span class="tref tref-diff tref-badge" aria-hidden="true">#${esc(opts.tdiffRefs.get(n.line))}</span>`
|
||||||
|
: '') +
|
||||||
(n.url ? '<span class="ext" aria-hidden="true">↗</span>' : '') +
|
(n.url ? '<span class="ext" aria-hidden="true">↗</span>' : '') +
|
||||||
riskMark +
|
riskMark +
|
||||||
sizeBadge +
|
sizeBadge +
|
||||||
|
|||||||
@@ -779,6 +779,15 @@
|
|||||||
bräche eine zu breit geratene Zeile sonst am Bindestrich mitten im Token
|
bräche eine zu breit geratene Zeile sonst am Bindestrich mitten im Token
|
||||||
(`#US-`/`123`). Die Spanne setzt render.js (markTicketRefs). */
|
(`#US-`/`123`). Die Spanne setzt render.js (markTicketRefs). */
|
||||||
.node .tref{white-space:nowrap}
|
.node .tref{white-space:nowrap}
|
||||||
|
/* Abweichungs-Marke (D91-Nachtrag 10): Das Ticket hinter dieser Ref sagt
|
||||||
|
etwas anderes als die Statusbox — die Ref selbst wird warnfarben, kein
|
||||||
|
neues Element in den ohnehin dichten Knoten-Ecken. Nur Anzeige aus der
|
||||||
|
Sitzung: nicht im Grafikexport (der liest die Knotenfarbe, nicht die
|
||||||
|
Spanne) und nicht im Druck. */
|
||||||
|
.node .tref-diff{color:var(--warn);font-weight:600}
|
||||||
|
/* Das Badge für Refs, die (nur) die Knoten-ID sind — dort steht die Ref
|
||||||
|
sonst nirgends im Knoten. Klein und nachgestellt wie die ”-Marke. */
|
||||||
|
.node .tref-badge{margin-left:4px;font-size:.86em}
|
||||||
/* Der Knopf trägt das Zeichen selbst statt eines Icons — in der Größe der
|
/* Der Knopf trägt das Zeichen selbst statt eines Icons — in der Größe der
|
||||||
SVGs daneben, damit die Kopfzeile ihre Höhe behält. */
|
SVGs daneben, damit die Kopfzeile ihre Höhe behält. */
|
||||||
.copybtn.idsbtn span{
|
.copybtn.idsbtn span{
|
||||||
@@ -1635,6 +1644,10 @@
|
|||||||
*{-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important}
|
*{-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important}
|
||||||
/* Knoten nicht über den Seitenrand zerschneiden */
|
/* Knoten nicht über den Seitenrand zerschneiden */
|
||||||
#out li{break-inside:avoid}
|
#out li{break-inside:avoid}
|
||||||
|
/* Die Abweichungs-Marke hängt an der Sitzung (Anmeldung, Abrufzeit) —
|
||||||
|
gedruckt wird der Plan, nicht mein Abrufstand (D91-Nachtrag 10). */
|
||||||
|
.node .tref-diff{color:inherit!important;font-weight:inherit!important}
|
||||||
|
.node .tref-badge{display:none!important}
|
||||||
/* Editierhilfe bzw. Zuruf, nicht drucken (D25, Fokusmarke SPEC §1).
|
/* Editierhilfe bzw. Zuruf, nicht drucken (D25, Fokusmarke SPEC §1).
|
||||||
Bei der Cursor-Zeile gehören Erhebung und Puls mit dazu — sonst stünde ein
|
Bei der Cursor-Zeile gehören Erhebung und Puls mit dazu — sonst stünde ein
|
||||||
Knoten im Druck grundlos vergrößert und schief zu seiner Anschlusslinie. */
|
Knoten im Druck grundlos vergrößert und schief zu seiner Anschlusslinie. */
|
||||||
|
|||||||
@@ -113,6 +113,58 @@ export function statusListPath(ref, slug){
|
|||||||
'?slug=' + encodeURIComponent(slug);
|
'?slug=' + encodeURIComponent(slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Alle Ticket-Refs eines Baums, gruppiert nach ihrem geerbten Projekt-Slug
|
||||||
|
(D91-Nachtrag 10): die Menge, die die Bulk-Abfrage holt — die Refs im Plan
|
||||||
|
sind der Filter, nie die Projektgröße. Knoten ohne Slug fallen heraus
|
||||||
|
(ohne Projekt ist eine Ref nicht auflösbar — dieselbe stille Regel wie
|
||||||
|
beim Öffnen, D91-Nachtrag 5). Map slug -> [{ref, node}]; dieselbe Ref an
|
||||||
|
mehreren Knoten steht mehrfach darin (markiert wird je Knoten),
|
||||||
|
dedupliziert wird erst im Anfrage-Pfad. */
|
||||||
|
export function collectTicketRefs(roots, slugMap){
|
||||||
|
const out = new Map();
|
||||||
|
(function w(ns){
|
||||||
|
for(const n of ns){
|
||||||
|
const ref = ticketRefOf(n);
|
||||||
|
const slug = slugMap.get(n);
|
||||||
|
if(ref && slug){
|
||||||
|
if(!out.has(slug)) out.set(slug, []);
|
||||||
|
out.get(slug).push({ref, node: n});
|
||||||
|
}
|
||||||
|
w(n.children || []);
|
||||||
|
}
|
||||||
|
})(roots);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Bulk-Pfad am Proxy (eine Anfrage je Projekt): Refs dedupliziert und
|
||||||
|
bei 200 gedeckelt — der benannte Server-Deckel; mehr referenziert kein
|
||||||
|
Plan, und die ersten 200 markiert zu bekommen ist besser als wegen eines
|
||||||
|
400 gar keine. */
|
||||||
|
export function bulkPath(slug, refs){
|
||||||
|
const uniq = [...new Set(refs)].slice(0, 200);
|
||||||
|
if(!uniq.length || !slug) return null;
|
||||||
|
return '/tickets?slug=' + encodeURIComponent(slug) + '&refs=' + uniq.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Steht die Ref sichtbar im LABEL des Knotens? Nein heißt: Sie ist (nur)
|
||||||
|
seine Knoten-ID (§1, erstes `#`-Token) — die Abweichungs-Marke braucht
|
||||||
|
dann ein Badge hinter dem Label als sichtbaren Träger (D91-Nachtrag 10).
|
||||||
|
Ein D60-Knoten (die ID vertritt das Label) zählt als sichtbar. */
|
||||||
|
export function refVisibleInLabel(n, ref){
|
||||||
|
return new RegExp('(^|\\s)#' + ref + '(?=\\s|$)').test(n.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Weicht der Ticket-Status von der Statusbox des Knotens ab? (Die
|
||||||
|
Abweichungs-Marke im Diagramm, D91-Nachtrag 10.) Verglichen wird nur, was
|
||||||
|
abbildbar ist — ein unbekannter Spaltenname sagt nichts über den Plan
|
||||||
|
(dieselbe Regel wie im Knoten-Fenster). Ein Knoten OHNE Statusbox weicht
|
||||||
|
von jedem abgebildeten Ticket-Status ab — auch das zeigt das Fenster so. */
|
||||||
|
export function ticketDiverges(node, statusName){
|
||||||
|
const ticket = mapTaigaStatus(statusName);
|
||||||
|
if(!ticket) return false;
|
||||||
|
return (node.status ? node.status.code : null) !== ticket.code;
|
||||||
|
}
|
||||||
|
|
||||||
/* Die Ticket-Referenz unter der Schreibmarke (Strg+Klick im Text,
|
/* Die Ticket-Referenz unter der Schreibmarke (Strg+Klick im Text,
|
||||||
D91-Nachtrag 5): ein FREISTEHENDES `#US-123`/`#T-1234`-Token im Baumteil.
|
D91-Nachtrag 5): ein FREISTEHENDES `#US-123`/`#T-1234`-Token im Baumteil.
|
||||||
Dieselben Ausschlüsse wie beim Abhängigkeits-Sprung (D67): nicht im
|
Dieselben Ausschlüsse wie beim Abhängigkeits-Sprung (D67): nicht im
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { parse } from '../src/parser.js';
|
import { parse } from '../src/parser.js';
|
||||||
import { taigaSlugs } from '../src/model.js';
|
import { taigaSlugs } from '../src/model.js';
|
||||||
import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, refParts, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath, storyAncestor } from '../src/taiga.js';
|
import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, refParts, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath, storyAncestor, collectTicketRefs, bulkPath, ticketDiverges, refVisibleInLabel } from '../src/taiga.js';
|
||||||
import { setStatusBox } from '../src/parser.js';
|
import { setStatusBox } from '../src/parser.js';
|
||||||
|
|
||||||
/* Schlagworte `&tag` (SPEC §1, D91): Extraktion im Parser und die
|
/* Schlagworte `&tag` (SPEC §1, D91): Extraktion im Parser und die
|
||||||
@@ -188,6 +188,96 @@ describe('storyAncestor — der nächste Vorfahr mit Story-Ref (D91-Nachtrag 9)'
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('collectTicketRefs — die Refs eines Baums je Projekt (D91-Nachtrag 10)', () => {
|
||||||
|
const collect = text => {
|
||||||
|
const { roots } = parse(text);
|
||||||
|
return collectTicketRefs(roots, taigaSlugs(roots));
|
||||||
|
};
|
||||||
|
|
||||||
|
it('gruppiert nach dem geerbten Slug, mit Knoten und Zeile', () => {
|
||||||
|
const m = collect('- P &taiga.a\n - S #US-1\n - T #T-2\n- Q &taiga.b\n - R #US-3');
|
||||||
|
expect([...m.keys()]).toEqual(['a', 'b']);
|
||||||
|
expect(m.get('a').map(e => e.ref)).toEqual(['US-1', 'T-2']);
|
||||||
|
expect(m.get('a')[0].node.line).toBe(2);
|
||||||
|
expect(m.get('b').map(e => e.ref)).toEqual(['US-3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Slug fällt die Ref heraus — ohne Projekt ist sie nicht auflösbar', () => {
|
||||||
|
const m = collect('- P\n - S #US-1');
|
||||||
|
expect(m.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Knoten ohne Ref stehen nicht darin', () => {
|
||||||
|
const m = collect('- P &taiga.a\n - ohne Ref\n - S #US-1');
|
||||||
|
expect(m.get('a').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein übersteuernder Slug ordnet den Teilbaum seinem Projekt zu', () => {
|
||||||
|
const m = collect('- P &taiga.a\n - S #US-1\n - Fremd &taiga.b #US-9');
|
||||||
|
expect(m.get('a').map(e => e.ref)).toEqual(['US-1']);
|
||||||
|
expect(m.get('b').map(e => e.ref)).toEqual(['US-9']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bulkPath — der Bulk-Pfad am Proxy (D91-Nachtrag 10)', () => {
|
||||||
|
it('baut Slug (kodiert) und kommagetrennte Refs', () => {
|
||||||
|
expect(bulkPath('mi kunde', ['US-1', 'T-2']))
|
||||||
|
.toBe('/tickets?slug=mi%20kunde&refs=US-1,T-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupliziert — dieselbe Ref an zwei Knoten wird einmal geholt', () => {
|
||||||
|
expect(bulkPath('a', ['US-1', 'US-1', 'T-2'])).toBe('/tickets?slug=a&refs=US-1,T-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deckelt bei 200 — der benannte Server-Deckel', () => {
|
||||||
|
const viele = Array.from({length: 250}, (_, i) => 'US-' + i);
|
||||||
|
const pfad = bulkPath('a', viele);
|
||||||
|
expect(pfad.split(',').length).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('null ohne Refs oder ohne Slug', () => {
|
||||||
|
expect(bulkPath('a', [])).toBe(null);
|
||||||
|
expect(bulkPath('', ['US-1'])).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refVisibleInLabel — braucht die Marke ein Badge? (D91-Nachtrag 10)', () => {
|
||||||
|
const node = text => parse(text).roots[0];
|
||||||
|
|
||||||
|
it('mit eigener Knoten-ID bleibt die Ref im Label: sichtbar', () => {
|
||||||
|
expect(refVisibleInLabel(node('- #auth: Login #US-123'), 'US-123')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('als Knoten-ID steht sie nicht im Label: unsichtbar, Badge nötig', () => {
|
||||||
|
expect(refVisibleInLabel(node('- Login bauen #US-123'), 'US-123')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein D60-Knoten (die ID vertritt das Label) zählt als sichtbar', () => {
|
||||||
|
expect(refVisibleInLabel(node('- #US-123'), 'US-123')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ticketDiverges — die Abweichungs-Marke (D91-Nachtrag 10)', () => {
|
||||||
|
const node = text => parse(text).roots[0];
|
||||||
|
|
||||||
|
it('markiert, wo Ticket und Statusbox Verschiedenes sagen', () => {
|
||||||
|
expect(ticketDiverges(node('- [ ] A #US-1'), 'In progress')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('einig — keine Marke', () => {
|
||||||
|
expect(ticketDiverges(node('- [~] A #US-1'), 'In progress')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein unbekannter Spaltenname sagt nichts über den Plan', () => {
|
||||||
|
expect(ticketDiverges(node('- [ ] A #US-1'), 'Blocked upstream')).toBe(false);
|
||||||
|
expect(ticketDiverges(node('- [ ] A #US-1'), null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Knoten ohne Statusbox weicht von jedem abgebildeten Status ab', () => {
|
||||||
|
expect(ticketDiverges(node('- A #US-1'), 'New')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('appendToken — Token ans sichtbare Zeilenende (D91)', () => {
|
describe('appendToken — Token ans sichtbare Zeilenende (D91)', () => {
|
||||||
it('hängt hinter den Inhalt an', () => {
|
it('hängt hinter den Inhalt an', () => {
|
||||||
expect(appendToken(' - [ ] Backend (M)', '#US-123')).toBe(' - [ ] Backend (M) #US-123');
|
expect(appendToken(' - [ ] Backend (M)', '#US-123')).toBe(' - [ ] Backend (M) #US-123');
|
||||||
|
|||||||
Reference in New Issue
Block a user