Merge branch 'fix/qa-report-i18n-batch' into 'main'

fix(qa-report): version tag display, /drafts redirect, audit filter, i18n cleanup

See merge request 2-6/2-6-4/terravault/ordinis!226
This commit is contained in:
Александр Зимин
2026-05-15 19:04:29 +00:00
8 changed files with 201 additions and 28 deletions
+20 -1
View File
@@ -213,7 +213,26 @@ export type BulkCloseResponse = {
errors: { businessKey: string; reason: string }[] errors: { businessKey: string; reason: string }[]
} }
export type AuditAction = 'CREATE' | 'UPDATE' | 'CLOSE' /**
* Все backend audit action types — должны matchить AuditLogger.write(...) +
* SchemaDraftService.applyDecision(...) + DraftService.emitDraftEvent(...).
* QA report BUG-004: фильтр в audit предлагал только 3 (CREATE/UPDATE/CLOSE),
* остальные 8 не были фильтруемы.
*/
export type AuditAction =
| 'CREATE'
| 'UPDATE'
| 'CLOSE'
| 'DEFINITION_CREATE'
| 'DEFINITION_UPDATE'
| 'DRAFT_CREATE'
| 'DRAFT_SUBMIT'
| 'DRAFT_REVIEW'
| 'DRAFT_APPROVE'
| 'DRAFT_REJECT'
| 'DRAFT_CHANGES_REQUESTED'
| 'DRAFT_WITHDRAW'
| 'DRAFT_PUBLISH'
export type AuditEntry = { export type AuditEntry = {
id: number id: number
@@ -220,17 +220,21 @@ function badgeVariant(
* Event type → short russian label для chip. Server-side resolution лучше, * Event type → short russian label для chip. Server-side resolution лучше,
* но пока mapping inline здесь — backend payloadPreview уже несёт полный * но пока mapping inline здесь — backend payloadPreview уже несёт полный
* человеческий текст, chip это просто категория. * человеческий текст, chip это просто категория.
*
* <p>UX-005 fix: были английские labels (submit/approved/rejected) — chip
* читается носителями русского как «Draft approved 15.05.2026», что выглядит
* как baked-in EN текст. Теперь короткие русские.
*/ */
function humanEventType(eventType: string): string { function humanEventType(eventType: string): string {
const map: Record<string, string> = { const map: Record<string, string> = {
RecordDraftSubmitted: 'submit', RecordDraftSubmitted: 'отправлен',
RecordDraftApproved: 'approved', RecordDraftApproved: 'одобрен',
RecordDraftRejected: 'rejected', RecordDraftRejected: 'отклонён',
RecordDraftWithdrawn: 'withdrawn', RecordDraftWithdrawn: 'отозван',
SchemaDraftSubmitted: 'schema submit', SchemaDraftSubmitted: 'схема: отправлена',
SchemaDraftApproved: 'schema approved', SchemaDraftApproved: 'схема: одобрена',
SchemaDraftRejected: 'schema rejected', SchemaDraftRejected: 'схема: отклонена',
SchemaDraftPublished: 'schema published', SchemaDraftPublished: 'схема: опубликована',
} }
return map[eventType] ?? eventType return map[eventType] ?? eventType
} }
@@ -1,3 +1,4 @@
import type * as React from 'react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { SparkleIcon } from '@phosphor-icons/react' import { SparkleIcon } from '@phosphor-icons/react'
@@ -136,6 +137,32 @@ function EntryCard({ entry, isUnread }: { entry: ChangelogEntry; isUnread: boole
) )
} }
/**
* Mini inline-markdown renderer для changelog описаний. UX-006: тексты
* вроде «**График доставки webhook'ов**» рендерились с буквальными
* звёздочками. Pulling full markdown library overkill для трёх правил.
*
* <p>Поддерживает: {@code **bold**}, {@code *italic*}, {@code `code`}.
* Stops parsing на первом неподдержанном паттерне — текст остаётся как есть.
*/
function renderInlineMarkdown(text: string): React.ReactNode {
const parts: React.ReactNode[] = []
// Pattern matches **bold**, `code`, или *italic* (lazy, non-greedy).
const pattern = /(\*\*([^*\n]+?)\*\*|`([^`\n]+?)`|\*([^*\n]+?)\*)/g
let lastIndex = 0
let key = 0
let m: RegExpExecArray | null
while ((m = pattern.exec(text)) !== null) {
if (m.index > lastIndex) parts.push(text.slice(lastIndex, m.index))
if (m[2] !== undefined) parts.push(<strong key={key++}>{m[2]}</strong>)
else if (m[3] !== undefined) parts.push(<code key={key++} className="text-mono text-cap bg-surface-2 px-1 rounded-sm">{m[3]}</code>)
else if (m[4] !== undefined) parts.push(<em key={key++}>{m[4]}</em>)
lastIndex = pattern.lastIndex
}
if (lastIndex < text.length) parts.push(text.slice(lastIndex))
return parts.length > 0 ? parts : text
}
function SectionBlock({ section }: { section: ChangelogSection }) { function SectionBlock({ section }: { section: ChangelogSection }) {
return ( return (
<div> <div>
@@ -151,7 +178,7 @@ function SectionBlock({ section }: { section: ChangelogSection }) {
{item.scope && ( {item.scope && (
<span className="text-mono text-cap text-accent mr-1.5">{item.scope}:</span> <span className="text-mono text-cap text-accent mr-1.5">{item.scope}:</span>
)} )}
<span>{item.description}</span> <span>{renderInlineMarkdown(item.description)}</span>
{item.commit && item.commitUrl && ( {item.commit && item.commitUrl && (
<a <a
href={item.commitUrl} href={item.commitUrl}
+34 -14
View File
@@ -55,7 +55,7 @@ i18n
'nav.reviews': 'Согласования', 'nav.reviews': 'Согласования',
'nav.myDrafts': 'Мои черновики', 'nav.myDrafts': 'Мои черновики',
'myDrafts.title': 'Мои черновики', 'myDrafts.title': 'Мои черновики',
'myDrafts.description': 'Все ваши submissions с историей: pending, approved, rejected, withdrawn.', 'myDrafts.description': 'Все ваши черновики с историей: ожидает рассмотрения, одобрен, отклонён, отозван.',
'myDrafts.empty': 'Вы пока не отправляли черновиков.', 'myDrafts.empty': 'Вы пока не отправляли черновиков.',
'myDrafts.total_one': '{{count}} черновик', 'myDrafts.total_one': '{{count}} черновик',
'myDrafts.total_few': '{{count}} черновика', 'myDrafts.total_few': '{{count}} черновика',
@@ -66,18 +66,18 @@ i18n
'myDrafts.col.status': 'Статус', 'myDrafts.col.status': 'Статус',
'myDrafts.col.submitted': 'Отправлено', 'myDrafts.col.submitted': 'Отправлено',
'myDrafts.col.reviewed': 'Решено', 'myDrafts.col.reviewed': 'Решено',
'myDrafts.col.reviewer': 'Reviewer', 'myDrafts.col.reviewer': 'Рецензент',
'myDrafts.col.comment': 'Комментарий', 'myDrafts.col.comment': 'Комментарий',
'myDrafts.col.actions': 'Действия', 'myDrafts.col.actions': 'Действия',
'myDrafts.action.withdraw': 'Отозвать', 'myDrafts.action.withdraw': 'Отозвать',
'myDrafts.confirmWithdraw': 'Отозвать draft? Это безвозвратно — статус станет WITHDRAWN.', 'myDrafts.confirmWithdraw': 'Отозвать draft? Это безвозвратно — статус станет WITHDRAWN.',
'reviews.title': 'Очередь согласований', 'reviews.title': 'Очередь согласований',
'reviews.description': 'Pending drafts от makers, ждут approve/reject. Pool model: первый approver обрабатывает.', 'reviews.description': 'Черновики от авторов, ждут одобрения или отклонения. Общая очередь: первый рецензент закрывает заявку.',
'reviews.empty': 'Нет pending согласований. Все обработаны 👍', 'reviews.empty': 'Нет черновиков на согласование. Все обработаны 👍',
'reviews.queueTotal_one': '{{count}} pending draft', 'reviews.queueTotal_one': '{{count}} черновик в очереди',
'reviews.queueTotal_few': '{{count}} pending draft', 'reviews.queueTotal_few': '{{count}} черновика в очереди',
'reviews.queueTotal_many': '{{count}} pending drafts', 'reviews.queueTotal_many': '{{count}} черновиков в очереди',
'reviews.queueTotal_other': '{{count}} pending drafts', 'reviews.queueTotal_other': '{{count}} черновиков в очереди',
'reviews.col.dict': 'Справочник', 'reviews.col.dict': 'Справочник',
'reviews.col.bk': 'Business key', 'reviews.col.bk': 'Business key',
'reviews.col.op': 'Операция', 'reviews.col.op': 'Операция',
@@ -284,9 +284,19 @@ i18n
'audit.action.expand': 'Раскрыть', 'audit.action.expand': 'Раскрыть',
'audit.action.export': 'Экспорт CSV', 'audit.action.export': 'Экспорт CSV',
'audit.empty': 'Нет записей по выбранным фильтрам', 'audit.empty': 'Нет записей по выбранным фильтрам',
'audit.action.CREATE': 'Создание', 'audit.action.CREATE': 'Создание записи',
'audit.action.UPDATE': 'Изменение', 'audit.action.UPDATE': 'Изменение записи',
'audit.action.CLOSE': 'Закрытие', 'audit.action.CLOSE': 'Закрытие записи',
'audit.action.DEFINITION_CREATE': 'Создание справочника',
'audit.action.DEFINITION_UPDATE': 'Изменение схемы',
'audit.action.DRAFT_CREATE': 'Черновик создан',
'audit.action.DRAFT_SUBMIT': 'Черновик отправлен',
'audit.action.DRAFT_REVIEW': 'Черновик в очереди',
'audit.action.DRAFT_APPROVE': 'Черновик одобрен',
'audit.action.DRAFT_REJECT': 'Черновик отклонён',
'audit.action.DRAFT_CHANGES_REQUESTED': 'Запрошены правки',
'audit.action.DRAFT_WITHDRAW': 'Черновик отозван',
'audit.action.DRAFT_PUBLISH': 'Черновик опубликован',
'audit.diff.before': 'Было', 'audit.diff.before': 'Было',
'audit.diff.after': 'Стало', 'audit.diff.after': 'Стало',
'audit.page.of': 'Стр. {{cur}} из {{total}}', 'audit.page.of': 'Стр. {{cur}} из {{total}}',
@@ -1099,9 +1109,19 @@ i18n
'audit.action.expand': 'Expand', 'audit.action.expand': 'Expand',
'audit.action.export': 'Export CSV', 'audit.action.export': 'Export CSV',
'audit.empty': 'No entries match the filters', 'audit.empty': 'No entries match the filters',
'audit.action.CREATE': 'Create', 'audit.action.CREATE': 'Record created',
'audit.action.UPDATE': 'Update', 'audit.action.UPDATE': 'Record updated',
'audit.action.CLOSE': 'Close', 'audit.action.CLOSE': 'Record closed',
'audit.action.DEFINITION_CREATE': 'Dictionary created',
'audit.action.DEFINITION_UPDATE': 'Schema updated',
'audit.action.DRAFT_CREATE': 'Draft created',
'audit.action.DRAFT_SUBMIT': 'Draft submitted',
'audit.action.DRAFT_REVIEW': 'Draft in queue',
'audit.action.DRAFT_APPROVE': 'Draft approved',
'audit.action.DRAFT_REJECT': 'Draft rejected',
'audit.action.DRAFT_CHANGES_REQUESTED': 'Changes requested',
'audit.action.DRAFT_WITHDRAW': 'Draft withdrawn',
'audit.action.DRAFT_PUBLISH': 'Draft published',
'audit.diff.before': 'Before', 'audit.diff.before': 'Before',
'audit.diff.after': 'After', 'audit.diff.after': 'After',
'audit.page.of': 'Page {{cur}} of {{total}}', 'audit.page.of': 'Page {{cur}} of {{total}}',
+21
View File
@@ -15,6 +15,7 @@ import { Route as ReviewsRouteImport } from './routes/reviews'
import { Route as OutboxRouteImport } from './routes/outbox' import { Route as OutboxRouteImport } from './routes/outbox'
import { Route as MyDraftsRouteImport } from './routes/my-drafts' import { Route as MyDraftsRouteImport } from './routes/my-drafts'
import { Route as GraphRouteImport } from './routes/graph' import { Route as GraphRouteImport } from './routes/graph'
import { Route as DraftsRouteImport } from './routes/drafts'
import { Route as DictionariesRouteImport } from './routes/dictionaries' import { Route as DictionariesRouteImport } from './routes/dictionaries'
import { Route as AuditRouteImport } from './routes/audit' import { Route as AuditRouteImport } from './routes/audit'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
@@ -54,6 +55,11 @@ const GraphRoute = GraphRouteImport.update({
path: '/graph', path: '/graph',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const DraftsRoute = DraftsRouteImport.update({
id: '/drafts',
path: '/drafts',
getParentRoute: () => rootRouteImport,
} as any)
const DictionariesRoute = DictionariesRouteImport.update({ const DictionariesRoute = DictionariesRouteImport.update({
id: '/dictionaries', id: '/dictionaries',
path: '/dictionaries', path: '/dictionaries',
@@ -100,6 +106,7 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/audit': typeof AuditRoute '/audit': typeof AuditRoute
'/dictionaries': typeof DictionariesRouteWithChildren '/dictionaries': typeof DictionariesRouteWithChildren
'/drafts': typeof DraftsRoute
'/graph': typeof GraphRoute '/graph': typeof GraphRoute
'/my-drafts': typeof MyDraftsRoute '/my-drafts': typeof MyDraftsRoute
'/outbox': typeof OutboxRoute '/outbox': typeof OutboxRoute
@@ -115,6 +122,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/audit': typeof AuditRoute '/audit': typeof AuditRoute
'/drafts': typeof DraftsRoute
'/graph': typeof GraphRoute '/graph': typeof GraphRoute
'/my-drafts': typeof MyDraftsRoute '/my-drafts': typeof MyDraftsRoute
'/outbox': typeof OutboxRoute '/outbox': typeof OutboxRoute
@@ -131,6 +139,7 @@ export interface FileRoutesById {
'/': typeof IndexRoute '/': typeof IndexRoute
'/audit': typeof AuditRoute '/audit': typeof AuditRoute
'/dictionaries': typeof DictionariesRouteWithChildren '/dictionaries': typeof DictionariesRouteWithChildren
'/drafts': typeof DraftsRoute
'/graph': typeof GraphRoute '/graph': typeof GraphRoute
'/my-drafts': typeof MyDraftsRoute '/my-drafts': typeof MyDraftsRoute
'/outbox': typeof OutboxRoute '/outbox': typeof OutboxRoute
@@ -149,6 +158,7 @@ export interface FileRouteTypes {
| '/' | '/'
| '/audit' | '/audit'
| '/dictionaries' | '/dictionaries'
| '/drafts'
| '/graph' | '/graph'
| '/my-drafts' | '/my-drafts'
| '/outbox' | '/outbox'
@@ -164,6 +174,7 @@ export interface FileRouteTypes {
to: to:
| '/' | '/'
| '/audit' | '/audit'
| '/drafts'
| '/graph' | '/graph'
| '/my-drafts' | '/my-drafts'
| '/outbox' | '/outbox'
@@ -179,6 +190,7 @@ export interface FileRouteTypes {
| '/' | '/'
| '/audit' | '/audit'
| '/dictionaries' | '/dictionaries'
| '/drafts'
| '/graph' | '/graph'
| '/my-drafts' | '/my-drafts'
| '/outbox' | '/outbox'
@@ -196,6 +208,7 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
AuditRoute: typeof AuditRoute AuditRoute: typeof AuditRoute
DictionariesRoute: typeof DictionariesRouteWithChildren DictionariesRoute: typeof DictionariesRouteWithChildren
DraftsRoute: typeof DraftsRoute
GraphRoute: typeof GraphRoute GraphRoute: typeof GraphRoute
MyDraftsRoute: typeof MyDraftsRoute MyDraftsRoute: typeof MyDraftsRoute
OutboxRoute: typeof OutboxRoute OutboxRoute: typeof OutboxRoute
@@ -249,6 +262,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof GraphRouteImport preLoaderRoute: typeof GraphRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/drafts': {
id: '/drafts'
path: '/drafts'
fullPath: '/drafts'
preLoaderRoute: typeof DraftsRouteImport
parentRoute: typeof rootRouteImport
}
'/dictionaries': { '/dictionaries': {
id: '/dictionaries' id: '/dictionaries'
path: '/dictionaries' path: '/dictionaries'
@@ -340,6 +360,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AuditRoute: AuditRoute, AuditRoute: AuditRoute,
DictionariesRoute: DictionariesRouteWithChildren, DictionariesRoute: DictionariesRouteWithChildren,
DraftsRoute: DraftsRoute,
GraphRoute: GraphRoute, GraphRoute: GraphRoute,
MyDraftsRoute: MyDraftsRoute, MyDraftsRoute: MyDraftsRoute,
OutboxRoute: OutboxRoute, OutboxRoute: OutboxRoute,
+19 -1
View File
@@ -32,7 +32,25 @@ import { UserCell } from '@/lib/useUserDisplay'
import type { AuditAction, AuditEntry, AuditFilters } from '@/api/client' import type { AuditAction, AuditEntry, AuditFilters } from '@/api/client'
import { localTzOffset, parseFormDate } from '@/lib/dates' import { localTzOffset, parseFormDate } from '@/lib/dates'
const ACTIONS: AuditAction[] = ['CREATE', 'UPDATE', 'CLOSE'] // Все 12 audit action types — должны matchить backend (AuditLogger,
// SchemaDraftService.applyDecision, DraftService.emitDraftEvent).
// QA report BUG-004: фильтр предлагал только CREATE/UPDATE/CLOSE → DRAFT_*
// и DEFINITION_* events не были фильтруемы через UI.
const ACTIONS: AuditAction[] = [
'CREATE',
'UPDATE',
'CLOSE',
'DEFINITION_CREATE',
'DEFINITION_UPDATE',
'DRAFT_CREATE',
'DRAFT_SUBMIT',
'DRAFT_REVIEW',
'DRAFT_APPROVE',
'DRAFT_REJECT',
'DRAFT_CHANGES_REQUESTED',
'DRAFT_WITHDRAW',
'DRAFT_PUBLISH',
]
const ACTION_VALUES: ReadonlySet<AuditAction> = new Set(ACTIONS) const ACTION_VALUES: ReadonlySet<AuditAction> = new Set(ACTIONS)
const PAGE_SIZE_OPTIONS = [ const PAGE_SIZE_OPTIONS = [
+17
View File
@@ -0,0 +1,17 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
/**
* Legacy redirect: /drafts → /my-drafts.
*
* <p>QA report BUG-003: пользователи интуитивно набирают /drafts, получают
* наг 404 «Not Found» plain text. Каноничный роут /my-drafts (имя содержит
* «my» чтобы отличать от reviews — pool drafts от других makers).
*
* <p>Static redirect (beforeLoad throws redirect) — никакого component'а
* не рендерится, чтобы избежать flash empty page между navigation steps.
*/
export const Route = createFileRoute('/drafts')({
beforeLoad: () => {
throw redirect({ to: '/my-drafts' })
},
})
@@ -1,6 +1,7 @@
package cloud.nstart.terravault.ordinis.restapi.web; package cloud.nstart.terravault.ordinis.restapi.web;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.info.BuildProperties; import org.springframework.boot.info.BuildProperties;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -9,6 +10,8 @@ import org.springframework.web.bind.annotation.RestController;
import java.time.Instant; import java.time.Instant;
import java.util.Optional; import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** /**
* {@code GET /api/v1/version} — экспонирует server build identity для UI * {@code GET /api/v1/version} — экспонирует server build identity для UI
@@ -39,29 +42,73 @@ import java.util.Optional;
public class VersionController { public class VersionController {
private final Optional<BuildProperties> buildProps; private final Optional<BuildProperties> buildProps;
private final String imageTagEnv;
/**
* Image tag from helm-time env (`ORDINIS_BUILD_IMAGE_TAG`). Format: docker
* tag like `v2-31-2-e3c82775`. Maven build runs на main commit (без git tag),
* поэтому build-info build.tag всегда empty — в k8s deploy реальный tag
* приходит только из helm chart, не из jar metadata.
*
* <p>Empty default → fallback на BuildProperties.tag (тоже empty в проде,
* но valid в локальном dev если кто-то билдит на git tag).
*/
// BuildProperties может отсутствовать в dev — оптимально через Optional. // BuildProperties может отсутствовать в dev — оптимально через Optional.
public VersionController(@Autowired(required = false) BuildProperties buildProps) { public VersionController(
@Autowired(required = false) BuildProperties buildProps,
@Value("${ordinis.build.image-tag:}") String imageTagEnv) {
this.buildProps = Optional.ofNullable(buildProps); this.buildProps = Optional.ofNullable(buildProps);
this.imageTagEnv = imageTagEnv == null ? "" : imageTagEnv;
} }
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public VersionInfo current() { public VersionInfo current() {
String resolvedTag = resolveTag();
return buildProps.map(b -> new VersionInfo( return buildProps.map(b -> new VersionInfo(
b.getVersion(), b.getVersion(),
Optional.ofNullable(b.get("commit")).orElse("unknown"), Optional.ofNullable(b.get("commit")).orElse("unknown"),
Optional.ofNullable(b.get("branch")).orElse(""), Optional.ofNullable(b.get("branch")).orElse(""),
Optional.ofNullable(b.get("tag")).orElse(""), resolvedTag,
b.getTime() != null ? b.getTime().toString() : "" b.getTime() != null ? b.getTime().toString() : ""
)).orElseGet(() -> new VersionInfo( )).orElseGet(() -> new VersionInfo(
"0.0.0-dev", "0.0.0-dev",
"unknown", "unknown",
"", "",
"", resolvedTag,
Instant.now().toString() Instant.now().toString()
)); ));
} }
/**
* Resolve human-readable semver tag из docker image tag.
*
* <p>Helm passes ROW image.tag like {@code v2-31-2-e3c82775}. Frontend
* хочет {@code v2.31.2}. Strips trailing {@code -{8-12 hex}} commit suffix
* и заменяет dashes между digits на dots.
*
* <p>Examples:
* <ul>
* <li>{@code v2-31-2-e3c82775} → {@code v2.31.2}</li>
* <li>{@code v2-31-2} → {@code v2.31.2}</li>
* <li>{@code latest} → {@code latest} (passes через без транформации)</li>
* <li>{@code main-abc1234} → {@code main} (branch tag, strips sha)</li>
* <li>empty/null → BuildProperties fallback → empty</li>
* </ul>
*/
private static final Pattern SHA_SUFFIX = Pattern.compile("-[0-9a-f]{7,12}$");
String resolveTag() {
String raw = imageTagEnv;
if (raw == null || raw.isBlank() || raw.equals("latest")) {
return buildProps.map(b -> Optional.ofNullable(b.get("tag")).orElse("")).orElse("");
}
// Strip trailing -{sha} commit suffix
Matcher m = SHA_SUFFIX.matcher(raw);
String stripped = m.find() ? raw.substring(0, m.start()) : raw;
// Convert v2-31-2 → v2.31.2 (only between digits to preserve branch names)
return stripped.replaceAll("(?<=\\d)-(?=\\d)", ".");
}
/** /**
* Stable JSON shape для UI client. * Stable JSON shape для UI client.
* *