From ed85e0d80a9bd5dd2d04fa1587b5e180e57699cb Mon Sep 17 00:00:00 2001 From: busya Date: Mon, 13 Jul 2026 16:53:35 +0300 Subject: [PATCH] feat(git): clarify dashboard release flow --- .../api/routes/git/_repo_operations_routes.py | 24 +- .../git/__tests__/test_llm_extension.py | 45 ++++ backend/src/plugins/git/llm_extension.py | 48 ++++ backend/src/services/llm_prompt_templates.py | 13 + frontend/e2e/tests/git.e2e.js | 30 ++- .../assistant/MarkdownRenderer.svelte | 16 +- .../git/GitDeploymentPipeline.svelte | 37 ++- .../components/git/GitFeatureWorkflow.svelte | 16 +- .../src/lib/components/git/GitManager.svelte | 16 +- .../components/git/GitWorkspacePanel.svelte | 243 +++++++++++------- .../__tests__/GitDeploymentPipeline.test.ts | 33 +++ .../git/__tests__/GitWorkspacePanel.test.ts | 117 +++++++++ frontend/src/lib/i18n/locales/en/git.json | 33 ++- frontend/src/lib/i18n/locales/ru/git.json | 33 ++- .../src/lib/models/GitManagerModel.svelte.ts | 88 ++++++- .../models/__tests__/GitManagerModel.test.ts | 54 +++- .../__tests__/git-utils.workspace.test.ts | 58 +++++ frontend/src/services/git-utils.ts | 61 +++++ 18 files changed, 825 insertions(+), 140 deletions(-) create mode 100644 backend/src/plugins/git/__tests__/test_llm_extension.py create mode 100644 frontend/src/lib/components/git/__tests__/GitWorkspacePanel.test.ts create mode 100644 frontend/src/services/__tests__/git-utils.workspace.test.ts diff --git a/backend/src/api/routes/git/_repo_operations_routes.py b/backend/src/api/routes/git/_repo_operations_routes.py index d9c4b5359..4859ad1b5 100644 --- a/backend/src/api/routes/git/_repo_operations_routes.py +++ b/backend/src/api/routes/git/_repo_operations_routes.py @@ -361,6 +361,8 @@ async def get_commit_diff( async def generate_commit_message( dashboard_ref: str, env_id: str | None = None, + purpose: str = "commit", + language: str = "Russian", config_manager=Depends(get_config_manager), db: Session = Depends(get_db), _=Depends(has_permission("plugin:git", "EXECUTE")), @@ -370,13 +372,15 @@ async def generate_commit_message( from . import _resolve_dashboard_id_from_ref try: + if purpose not in {"commit", "summary"}: + raise HTTPException(status_code=422, detail="purpose must be 'commit' or 'summary'") dashboard_id = await _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) diff = await _await_service_result(_gs.get_diff(dashboard_id, staged=True)) if not diff or all(item is None for item in diff): diff = await _await_service_result(_gs.get_diff(dashboard_id, staged=False)) if not diff or all(item is None for item in diff): - return {"message": "No changes detected"} + return {"summary": ""} if purpose == "summary" else {"message": "No changes detected"} history_objs = await _await_service_result(_gs.get_commit_history(dashboard_id, limit=5)) history = [h.message for h in history_objs if hasattr(h, "message")] @@ -412,15 +416,23 @@ async def generate_commit_message( from src.plugins.git.llm_extension import GitLLMExtension extension = GitLLMExtension(client) + if purpose == "summary": + summary_prompt = llm_settings["prompts"].get( + "git_change_summary_prompt", + DEFAULT_LLM_PROMPTS["git_change_summary_prompt"], + ) + summary = await extension.summarize_changes( + diff, + language=language[:40] or "Russian", + prompt_template=summary_prompt, + ) + return {"summary": summary} + git_prompt = llm_settings["prompts"].get( "git_commit_prompt", DEFAULT_LLM_PROMPTS["git_commit_prompt"], ) - message = await extension.suggest_commit_message( - diff, - history, - prompt_template=git_prompt, - ) + message = await extension.suggest_commit_message(diff, history, prompt_template=git_prompt) return {"message": message} except HTTPException: raise diff --git a/backend/src/plugins/git/__tests__/test_llm_extension.py b/backend/src/plugins/git/__tests__/test_llm_extension.py new file mode 100644 index 000000000..8f34c1a72 --- /dev/null +++ b/backend/src/plugins/git/__tests__/test_llm_extension.py @@ -0,0 +1,45 @@ +# #region Test.Git.LLMSummary [C:3] [TYPE Module] [SEMANTICS test,git,llm,summary] +# @BRIEF Verify Git change summaries are authored by the configured LLM from grounded diff context. +# @RELATION BINDS_TO -> [GitLLMExtension.SummarizeChanges] +# @TEST_CONTRACT: Superset diff + language -> free-form LLM summary. +# @TEST_SCENARIO: provider_success -> response text is returned without deterministic reinterpretation. +# @TEST_EDGE: missing_field -> empty provider content is rejected by the production contract. +# @TEST_EDGE: invalid_type -> language is rendered as text without changing the diff. +# @TEST_EDGE: external_fail -> covered by frontend retry-state contract. + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from src.plugins.git.llm_extension import GitLLMExtension + + +# #region Test.Git.LLMSummary.Success [C:2] [TYPE Function] [SEMANTICS test,git,llm] +# @BRIEF The exact provider prose is returned and the grounding prompt contains diff and requested language. +@pytest.mark.anyio +async def test_summarize_changes_returns_provider_prose_and_grounded_prompt(): + create = AsyncMock( + return_value=SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="• Изменена логика фильтрации по регионам."))] + ) + ) + client = SimpleNamespace( + default_model="test-model", + client=SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create))), + ) + extension = GitLLMExtension(client) + diff = "diff --git a/dashboards/world.yaml b/dashboards/world.yaml\n-old filter\n+new filter" + + result = await extension.summarize_changes(diff, "Russian") + + assert result == "• Изменена логика фильтрации по регионам." + request = create.await_args.kwargs + prompt = request["messages"][0]["content"] + assert diff in prompt + assert "Russian" in prompt + assert request["temperature"] == 0.6 +# #endregion Test.Git.LLMSummary.Success + + +# #endregion Test.Git.LLMSummary diff --git a/backend/src/plugins/git/llm_extension.py b/backend/src/plugins/git/llm_extension.py index 27d06839a..ac7c0bdbe 100644 --- a/backend/src/plugins/git/llm_extension.py +++ b/backend/src/plugins/git/llm_extension.py @@ -61,6 +61,54 @@ class GitLLMExtension: return response.choices[0].message.content.strip() # endregion suggest_commit_message + + # #region GitLLMExtension.SummarizeChanges [C:4] [TYPE Function] [SEMANTICS git,llm,summary,bi] + # @ingroup Git + # @BRIEF Generate a grounded, BI-facing explanation of a Superset workspace diff. + # @PRE diff contains the current repository changes; language is a human-readable language name. + # @POST Returns non-empty free-form LLM prose without applying deterministic YAML interpretation. + # @SIDE_EFFECT Calls the configured external LLM provider and may take multiple minutes including retry. + # @RATIONALE The meaning of Superset YAML changes is context-sensitive; fixed key-to-label mappings create + # false certainty. The LLM receives the full diff with a grounding prompt and the UI exposes + # loading/error/retry states instead of substituting an algorithmic fallback. + # @REJECTED Parsing selected YAML keys into business statements was rejected because it silently omits + # unknown chart, dataset, filter, and plugin-specific changes. + @retry( + stop=stop_after_attempt(2), + wait=wait_exponential(multiplier=1, min=2, max=10), + reraise=True, + ) + async def summarize_changes( + self, + diff: str, + language: str, + prompt_template: str = DEFAULT_LLM_PROMPTS["git_change_summary_prompt"], + ) -> str: + with belief_scope("GitLLMExtension.summarize_changes"): + prompt = render_prompt( + prompt_template, + {"diff": diff, "language": language}, + ) + logger.reason( + "Requesting BI-facing Git change summary", + payload={"model": self.client.default_model, "diff_chars": len(diff), "language": language}, + ) + response = await self.client.client.chat.completions.create( + model=self.client.default_model, + messages=[{"role": "user", "content": prompt}], + temperature=0.6, + ) + if not response or not getattr(response, "choices", None): + raise RuntimeError("LLM returned no change summary") + summary = str(response.choices[0].message.content or "").strip() + if not summary: + raise RuntimeError("LLM returned an empty change summary") + logger.reflect( + "BI-facing Git change summary generated", + payload={"summary_chars": len(summary)}, + ) + return summary + # #endregion GitLLMExtension.SummarizeChanges # #endregion GitLLMExtension # #endregion GitLLMExtensionModule diff --git a/backend/src/services/llm_prompt_templates.py b/backend/src/services/llm_prompt_templates.py index b10b87fc2..688fc56ac 100644 --- a/backend/src/services/llm_prompt_templates.py +++ b/backend/src/services/llm_prompt_templates.py @@ -104,6 +104,19 @@ DEFAULT_LLM_PROMPTS: dict[str, str] = { "{diff}\n\n" "Commit Message:" ), + "git_change_summary_prompt": ( + "You explain Apache Superset dashboard changes to a BI analyst.\n" + "Based only on the Git diff below, describe what meaningfully changed in the dashboard.\n" + "Focus on dashboards, charts, datasets, filters, database permissions, and user-visible behavior.\n" + "Ignore metadata.yaml timestamps, fingerprints, file names, YAML syntax, Git mechanics, and formatting-only noise.\n" + "Do not invent business impact that is not supported by the diff.\n" + "Write in {language}. Return 1-4 short Markdown bullet points without a heading.\n" + "You may use bold and inline code. Do not use links, tables, HTML, or fenced code blocks.\n" + "If the diff contains no meaningful BI change, say that explicitly in one sentence.\n\n" + "Diff:\n" + "{diff}\n\n" + "Summary:" + ), } # #endregion DEFAULT_LLM_PROMPTS diff --git a/frontend/e2e/tests/git.e2e.js b/frontend/e2e/tests/git.e2e.js index 074a34ab9..6f605f25c 100644 --- a/frontend/e2e/tests/git.e2e.js +++ b/frontend/e2e/tests/git.e2e.js @@ -90,16 +90,44 @@ test.describe('Git Integration', () => { test.describe('Git dashboard recovery browser states', () => { test('release screen separates a validated publication from unsaved dashboard edits', async ({ authPage }) => { + await authPage.route('**/api/git/repositories/*/generate-message?*', async (route) => { + const requestUrl = new URL(route.request().url()); + if (requestUrl.searchParams.get('purpose') !== 'summary') { + await route.continue(); + return; + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + summary: '- Изменено **название** дашборда.\n- Разрешён параметр `allow_dml`.', + }), + }); + }); await authPage.goto(`${FRONTEND_URL}/git`); const dashboardRow = authPage.locator('tr').filter({ hasText: "World Bank's Data" }).first(); await expect(dashboardRow).toBeVisible(); await dashboardRow.getByRole('button', { name: /Управление Git|Manage Git/ }).click(); const dialog = authPage.getByRole('dialog', { name: /Управление Git|Git management/ }); - await expect(dialog.getByText(/Проверенная версия для пользователей|Validated version for users/)).toBeVisible(); + await expect(dialog.getByText(/Кандидат на публикацию|Publication candidate/)).toBeVisible(); await expect(dialog.getByRole('button', { name: /Опубликовать проверенную версию|Publish validated version/ })).toBeVisible(); await expect(dialog.getByText(/Есть несохранённые изменения|There are unsaved changes/)).toBeVisible(); await expect(dialog.getByText(/в публикацию не войдут|will not be included in the current publication/)).toBeVisible(); + await expect(dialog).not.toContainText('.superset-tools-fingerprint'); + await expect(dialog).not.toContainText('metadata.yaml'); + + await dialog.getByRole('button', { name: /Перейти к сохранению|Go to saving/ }).click(); + await expect(dialog.locator('#git-commit-message')).toBeFocused(); + await expect(dialog.getByRole('button', { name: /^Сохранить версию$|^Save version$/ })).toBeVisible(); + await expect(dialog.getByText(/Ключевые изменения|Key changes/)).toBeVisible(); + const renderedSummary = dialog.getByTestId('workspace-summary-markdown'); + await expect(renderedSummary.locator('li')).toHaveCount(2); + await expect(renderedSummary.locator('strong')).toHaveText('название'); + await expect(renderedSummary.locator('code')).toHaveText('allow_dml'); + await expect(renderedSummary).not.toContainText('**название**'); + const technicalDiff = dialog.getByText(/Технический YAML diff|Technical YAML diff/).locator('..'); + await expect(technicalDiff).not.toHaveAttribute('open', ''); }); test('checkout conflict state exposes scoped recovery actions', async ({ page }) => { diff --git a/frontend/src/lib/components/assistant/MarkdownRenderer.svelte b/frontend/src/lib/components/assistant/MarkdownRenderer.svelte index 2cf5510f6..d0cab64a5 100644 --- a/frontend/src/lib/components/assistant/MarkdownRenderer.svelte +++ b/frontend/src/lib/components/assistant/MarkdownRenderer.svelte @@ -5,15 +5,25 @@ + {#if source}
- + {#if blockHtml} + + {:else} + + {/if}
{/if} diff --git a/frontend/src/lib/components/git/GitDeploymentPipeline.svelte b/frontend/src/lib/components/git/GitDeploymentPipeline.svelte index fb402f194..adc41c1bf 100644 --- a/frontend/src/lib/components/git/GitDeploymentPipeline.svelte +++ b/frontend/src/lib/components/git/GitDeploymentPipeline.svelte @@ -26,12 +26,14 @@ drift_status?: 'in_sync' | 'drifted' | 'unknown' | null; }; type DeploymentStatus = { environments: EnvironmentStatus[]; current_content_hash: string | null }; + type CandidateCommit = { hash: string; message?: string; author?: string; timestamp?: string | Date }; type PipelineAction = 'sync' | 'deploy_preprod' | 'validate_preprod' | 'deploy_prod' | 'current'; let { deploymentStatus = null as DeploymentStatus | null, currentVersionDate = null as string | null, currentVersionHash = null as string | null, + candidateCommit = null as CandidateCommit | null, hasWorkspaceChanges = false, changedFilesCount = 0, validatingPreprod = false, @@ -51,6 +53,9 @@ // A shared PREPROD tests one immutable release candidate. DEV may advance while it is tested. let preprodValidated = $derived(Boolean(preprod?.commit_hash && preprod?.validation_status === 'validated')); let prodMatchesPreprod = $derived(Boolean(preprod?.content_hash && prod?.content_hash === preprod.content_hash)); + let prodHasSameGitVersionButDifferentContent = $derived(Boolean( + prod?.commit_hash && currentVersionHash && prod.commit_hash === currentVersionHash && !prodMatchesDev + )); let action = $derived.by((): PipelineAction => { if (!currentHash) return 'sync'; if (preprod?.commit_hash && preprod.validation_status === 'validated' && !prodMatchesPreprod) return 'deploy_prod'; @@ -73,9 +78,12 @@ if (action === 'deploy_prod' && preprod?.commit_hash) { const version = preprod.commit_hash.slice(0, 12); const base = (git.pipeline_message_deploy_prod || 'В PROD будет опубликована проверенная версия {version}.').replace('{version}', version); - return hasWorkspaceChanges - ? `${base} ${(git.pipeline_workspace_excluded || 'Текущие несохранённые изменения ({count} файлов) в публикацию не войдут.').replace('{count}', String(changedFilesCount))}` + const publication = prodHasSameGitVersionButDifferentContent + ? `${base} ${git.pipeline_message_resolve_drift || 'Публикация перезапишет отличающееся содержимое PROD и устранит drift.'}` : base; + return hasWorkspaceChanges + ? `${publication} ${(git.pipeline_workspace_excluded || 'Текущие несохранённые изменения ({count} файлов) в публикацию не войдут.').replace('{count}', String(changedFilesCount))}` + : publication; } return action === 'deploy_preprod' && preprod?.commit_hash ? (git.pipeline_candidate_replaced || 'Новый кандидат заменит текущий кандидат PREPROD и потребует повторной проверки.') @@ -98,7 +106,8 @@ } if (!prod?.content_hash) return git.pipeline_not_deployed || 'Версия не развёрнута'; if (prod.drift_status === 'drifted') return 'Обнаружены ручные изменения'; - return prodMatchesDev ? git.pipeline_live || 'Пользователи видят эту версию' : git.pipeline_outdated || 'Не соответствует DEV'; + if (prodHasSameGitVersionButDifferentContent) return git.pipeline_content_drift || 'Содержимое PROD отличается'; + return prodMatchesDev ? git.pipeline_live || 'Пользователи видят эту версию' : git.pipeline_outdated || 'Пользователи видят другую версию'; } function stageHash(stage: string): string | null { if (stage === 'dev') return currentHash; @@ -135,7 +144,7 @@

{$t.git?.pipeline_title || 'Путь публикации дашборда'}

{$t.git?.pipeline_hint || 'Версия проходит разработку, проверку и публикацию по содержимому дашборда.'}

- {#if action !== 'current' && currentHash && prod?.content_hash} + {#if action !== 'current' && currentHash && prod?.content_hash && !prodHasSameGitVersionButDifferentContent} @@ -146,10 +155,18 @@
{#if preprod?.commit_hash}
- {$t.git?.pipeline_preprod_candidate || 'Проверенная версия для пользователей'}: - {preprod.commit_hash.slice(0, 12)} - · {preprod.validation_status === 'validated' ? 'проверен' : 'ожидает проверки'} - {#if !preprodMatchesDev}· {$t.git?.pipeline_dev_ahead || 'В DEV есть более новые изменения'}{/if} +
+ {$t.git?.pipeline_preprod_candidate || 'Кандидат на публикацию'}: + {#if candidateCommit?.message}{candidateCommit.message}{/if} + {preprod.commit_hash.slice(0, 12)} + · {preprod.validation_status === 'validated' ? ($t.git?.pipeline_candidate_validated || 'проверен') : ($t.git?.pipeline_candidate_pending || 'ожидает проверки')} +
+
+ {#if candidateCommit?.author}{$t.git?.pipeline_candidate_author || 'Автор'}: {candidateCommit.author}{/if} + {#if candidateCommit?.timestamp}{$t.git?.pipeline_candidate_created || 'Создан'}: {formatDate(String(candidateCommit.timestamp))}{/if} + {#if preprod.source_branch}{$t.git?.candidate_source || 'Источник'}: {preprod.source_branch}{/if} + {#if !preprodMatchesDev}· {$t.git?.pipeline_dev_ahead || 'В DEV есть более новые изменения'}{/if} +
{/if} @@ -165,7 +182,9 @@
{$t.git?.pipeline_version || 'Версия'}
{stageVersion(stage)}
{stage === 'dev' ? ($t.git?.pipeline_changed || 'Обновлена') : ($t.git?.pipeline_deployed || 'Развёрнута')}
{formatDate(stageDate(stage))}
{#if stage !== 'dev' && (stage === 'preprod' ? preprod?.drift_status : prod?.drift_status) === 'drifted'} -
Требуется синхронизация версии
+
{$t.git?.pipeline_sync_required || 'Требуется синхронизация версии'}
+ {:else if stage === 'prod' && prodHasSameGitVersionButDifferentContent} +
{$t.git?.pipeline_same_revision_different_content || 'Git-версия та же, но содержимое окружения отличается'}
{/if} diff --git a/frontend/src/lib/components/git/GitFeatureWorkflow.svelte b/frontend/src/lib/components/git/GitFeatureWorkflow.svelte index a4c5e8afc..7230420d1 100644 --- a/frontend/src/lib/components/git/GitFeatureWorkflow.svelte +++ b/frontend/src/lib/components/git/GitFeatureWorkflow.svelte @@ -75,11 +75,15 @@ } -
+
0 ? 'p-4' : 'px-4 py-3'}`} aria-label={$t.git?.feature_flow_title || 'Черновики доработок'}>

{$t.git?.feature_flow_title || 'Черновики доработок'}

-

{$t.git?.feature_flow_hint || 'Передайте готовую доработку в общую разработку DEV. Это ещё не отправляет её на проверку или пользователям.'}

+

+ {workingFeatureBranches.length > 0 + ? ($t.git?.feature_flow_hint || 'Передайте готовую доработку в общую разработку DEV. Это ещё не отправляет её на проверку или пользователям.') + : ($t.git?.feature_flow_no_active || 'Нет отдельных доработок, ожидающих передачи в DEV.')} +

{featureBranches.length} @@ -87,13 +91,7 @@
- {#if workingFeatureBranches.length === 0} -
- {archivedFeatureBranches.length > 0 - ? ($t.git?.feature_flow_no_active || 'Нет активных доработок: все черновики уже в DEV.') - : ($t.git?.feature_flow_empty || 'Нет отдельных черновиков. Создайте feature-ветку в технических деталях.')} -
- {:else} + {#if workingFeatureBranches.length > 0}
{#each workingFeatureBranches as branch (branch.name)} diff --git a/frontend/src/lib/components/git/GitManager.svelte b/frontend/src/lib/components/git/GitManager.svelte index e1834b222..001cb917f 100644 --- a/frontend/src/lib/components/git/GitManager.svelte +++ b/frontend/src/lib/components/git/GitManager.svelte @@ -106,6 +106,15 @@ function handleBackdropClick(e) { if (e.target === e.currentTarget) closeModal(); } + function navigateToVersionSave(): void { + model.activeTab = 'workspace'; + requestAnimationFrame(() => { + const target = document.getElementById('git-workspace-save'); + target?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + requestAnimationFrame(() => target?.querySelector('#git-commit-message')?.focus()); + }); + } + onMount(() => { model.initialize(); lastFocused = document.activeElement as HTMLElement | null; @@ -230,6 +239,7 @@ deploymentStatus={model.deploymentStatus} currentVersionDate={model.workspaceStatus?.last_commit_date || null} currentVersionHash={model.workspaceStatus?.last_commit_hash || null} + candidateCommit={model.environmentHistories.preprod?.find((commit) => commit.hash === model.deploymentStatus?.environments.find((environment) => environment.stage === 'preprod')?.commit_hash) || null} hasWorkspaceChanges={model.hasWorkspaceChanges} changedFilesCount={model.changedFilesCount} validatingPreprod={model.validatingPreprod} @@ -258,8 +268,8 @@

{$t.git?.workspace_pending_title || 'Есть несохранённые изменения'}

{($t.git?.workspace_pending_hint || '{count} файлов изменены. Они не войдут в текущую публикацию, пока вы не сохраните новую версию.').replace('{count}', String(model.changedFilesCount))}

-
{/if} @@ -342,7 +352,7 @@
{#if model.activeTab === 'workspace'} - model.handleSync()} onGenerateMessage={() => model.handleGenerateMessage()} onCommit={() => model.handleCommit()} /> + model.handleSync()} onGenerateMessage={() => model.handleGenerateMessage()} onGenerateSummary={() => model.handleGenerateWorkspaceSummary(true)} onCommit={() => model.handleCommit()} /> {:else if model.activeTab === 'release'} model.handlePromote()} onDeploy={() => model.openDeployModal()} onOpenHistory={() => (model.activeTab = 'workspace')} /> {:else} diff --git a/frontend/src/lib/components/git/GitWorkspacePanel.svelte b/frontend/src/lib/components/git/GitWorkspacePanel.svelte index 6bd4f44da..d6829f3c0 100644 --- a/frontend/src/lib/components/git/GitWorkspacePanel.svelte +++ b/frontend/src/lib/components/git/GitWorkspacePanel.svelte @@ -7,9 +7,12 @@ + + + - - + + +
- -
-
+
+
+

{$t.git?.workspace_save_title || 'Сохранить версию'}

+

{$t.git?.workspace_save_hint || 'Опишите смысл изменений — это описание увидят при проверке и публикации.'}

+
+ + + {#if hasWorkspaceChanges && !commitMessage.trim()} +

{$t.git?.workspace_description_required || 'Добавьте описание версии, чтобы сохранить её.'}

+ {/if} +
{$t.git?.files_with_changes || 'Файлов с изменениями:'} {changedFilesCount}
@@ -236,23 +252,16 @@ size="lg" > - {$t.git?.commit_button || 'Создать коммит'} + {$t.git?.workspace_save_button || 'Сохранить версию'} - -
- - -
-

{$t.git?.commit_message || 'Сообщение коммита'}

- +
+ {$t.git?.workspace_save_options || 'Параметры сохранения'} + +
@@ -276,7 +285,7 @@ class="flex-1" > - {$t.git?.sync_compact || '🔄 Sync'} + {$t.git?.workspace_refresh_changes || 'Обновить изменения'}
@@ -286,48 +295,15 @@
- {$t.git?.diff_title || 'Diff (изменения)'} + {$t.git?.workspace_changes_title || 'Изменения версии'}
{#if hasWorkspaceChanges} {($t.git?.files_count || '{count} файлов').replace('{count}', String(changedFilesCount))} - {#if totalChunks > 0} - · {renderedChunkCount}/{totalChunks} - {/if} {/if}
- {#if hasWorkspaceChanges && (workspaceDiff || changeSummary.length > 0)} -
- -
- {$t.git?.diff_view_mode || 'Diff mode'}: - - -
-
- {/if} {#if hasWorkspaceChanges && changeSummary.length > 0}
{$t.git?.semantic_summary || 'Change summary'}
@@ -340,24 +316,52 @@
{/if} - {#if hasWorkspaceChanges && workspaceDiff} -
- - - {$t.git?.advanced_raw_diff_title || $t.git?.raw_diff_title || 'Advanced: raw YAML diff'} - -
- -
{rawDiffLines.join('\n')}
- {#if rawDiffLines.length >= 600} -
{$t.git?.raw_diff_limited || 'Showing first 600 matching lines.'}
+ {#if hasWorkspaceChanges} +
+
+

{$t.git?.semantic_key_changes || 'Ключевые изменения'}

+ {#if workspaceSummaryState === 'ready'} + {/if}
-
+ + {#if workspaceSummaryState === 'loading'} +
+ +
+

{$t.git?.workspace_summary_loading || 'Агент анализирует изменения…'}

+

{$t.git?.workspace_summary_loading_hint || 'Это может занять несколько минут. Можно продолжать работу с версией.'}

+
+
+ {:else if workspaceSummaryState === 'ready' && workspaceSummary} +
+ +
+

{$t.git?.workspace_summary_ai_notice || 'Описание сформировано AI по текущему diff.'}

+ {:else if workspaceSummaryState === 'error'} + + {:else} + + {/if} + {/if} {#if workspaceLoading}
@@ -371,17 +375,68 @@
{:else if hasWorkspaceChanges && renderedHtml} - -

{$t.git?.diff_rendered_hidden_hint || 'Visual diff is hidden from screen reader — use the raw diff below.'}

- +
+ + + {$t.git?.technical_yaml_preview || 'Технический YAML diff'} + +
+
+ +
+ {$t.git?.diff_view_mode || 'Режим'}: + + +
+
+
+ + + {$t.git?.raw_diff_title || 'Текстовый YAML diff'} + +
+ +
{rawDiffLines.join('\n')}
+ {#if rawDiffLines.length >= 600} +
{$t.git?.raw_diff_limited || 'Показаны первые 600 подходящих строк.'}
+ {/if} +
+
+ +

{$t.git?.diff_rendered_hidden_hint || 'Visual diff is hidden from screen reader — use the raw diff below.'}

+ {#if hasMoreChunks}
{$t.git?.diff_loading || 'Загрузка изменений...'}
{:else} - { + {/if}
{:else if totalChunks > 0} @@ -407,6 +462,8 @@ {$t.git?.diff_all_shown?.replace('{count}', totalChunks) || `Показаны все изменения (${totalChunks} файлов)`}
{/if} + + {:else if hasWorkspaceChanges}
diff --git a/frontend/src/lib/components/git/__tests__/GitDeploymentPipeline.test.ts b/frontend/src/lib/components/git/__tests__/GitDeploymentPipeline.test.ts index b70d0b600..86760d7de 100644 --- a/frontend/src/lib/components/git/__tests__/GitDeploymentPipeline.test.ts +++ b/frontend/src/lib/components/git/__tests__/GitDeploymentPipeline.test.ts @@ -74,5 +74,38 @@ describe('GitDeploymentPipeline', () => { expect(screen.getByRole('button', { name: 'Создать новый кандидат PREPROD' })).toBeTruthy(); expect(screen.queryByRole('button', { name: 'Опубликовать в PROD' })).toBeNull(); }); + + it('shows a human-readable candidate description in addition to its technical id', () => { + render(GitDeploymentPipeline, { + deploymentStatus: validatedCandidate, + candidateCommit: { + hash: 'candidate-123456789', + message: 'Добавлен фильтр по региону', + author: 'BI Analyst', + timestamp: '2026-07-13T06:00:00Z', + }, + }); + + expect(screen.getByText('Добавлен фильтр по региону')).toBeTruthy(); + expect(screen.getByText(/Автор: BI Analyst/)).toBeTruthy(); + }); + + it('explains content drift when PROD has the same Git commit as DEV', () => { + render(GitDeploymentPipeline, { + currentVersionHash: 'same-commit', + deploymentStatus: { + current_content_hash: 'dev-content', + environments: [ + { stage: 'preprod', commit_hash: 'same-commit', content_hash: 'dev-content', deployed_at: null, validation_status: 'validated' }, + { stage: 'prod', commit_hash: 'same-commit', content_hash: 'prod-drift', deployed_at: null, validation_status: 'validated' }, + ], + }, + }); + + expect(screen.getByText('Содержимое PROD отличается')).toBeTruthy(); + expect(screen.getByText('Git-версия та же, но содержимое окружения отличается')).toBeTruthy(); + expect(screen.getByText(/Публикация перезапишет отличающееся содержимое PROD/)).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Сравнить с PROD' })).toBeNull(); + }); }); // #endregion Test.Git.DeploymentPipeline diff --git a/frontend/src/lib/components/git/__tests__/GitWorkspacePanel.test.ts b/frontend/src/lib/components/git/__tests__/GitWorkspacePanel.test.ts new file mode 100644 index 000000000..a4090dde1 --- /dev/null +++ b/frontend/src/lib/components/git/__tests__/GitWorkspacePanel.test.ts @@ -0,0 +1,117 @@ +// #region Test.Git.WorkspacePanel [C:3] [TYPE Module] [SEMANTICS test,git,workspace,bi] +// @BRIEF Verify asynchronous LLM meaning precedes technical YAML and saving explains its prerequisite. +// @RELATION BINDS_TO -> [GitWorkspacePanel] +// @TEST_CONTRACT: Workspace status + LLM state -> grounded summary UX + progressive technical disclosure. +// @TEST_SCENARIO: summary_ready -> free-form agent Markdown is rendered as semantic content. +// @TEST_EDGE: missing_description -> save is disabled and recovery guidance is visible. +// @TEST_EDGE: long_request -> progress is announced while save remains available. +// @TEST_EDGE: external_fail -> inline retry is available without replacing content algorithmically. +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import GitWorkspacePanel from '../GitWorkspacePanel.svelte'; + +vi.mock('$lib/i18n/index.svelte.js', () => ({ + t: { + subscribe(run: (value: { git: Record }) => void) { + run({ git: {} }); + return () => {}; + }, + }, +})); + +beforeAll(() => { + vi.stubGlobal('IntersectionObserver', class { + observe() {} + disconnect() {} + }); +}); + +const semanticDiff = [ + 'diff --git a/dashboards/world.yaml b/dashboards/world.yaml', + '--- a/dashboards/world.yaml', + '+++ b/dashboards/world.yaml', + '@@ -1 +1 @@', + '- dashboard_title: World Bank [draft]', + '+ dashboard_title: World Bank', + 'diff --git a/databases/examples.yaml b/databases/examples.yaml', + '--- a/databases/examples.yaml', + '+++ b/databases/examples.yaml', + '@@ -1 +1 @@', + '- allow_dml: false', + '+ allow_dml: true', +].join('\n'); + +describe('GitWorkspacePanel BI projection', () => { + it('shows the free-form agent summary before a collapsed technical diff', () => { + const { container } = render(GitWorkspacePanel, { + hasWorkspaceChanges: true, + changedFilesCount: 2, + workspaceStatus: { modified_files: ['dashboards/world.yaml', 'databases/examples.yaml'] }, + workspaceLoading: false, + workspaceDiff: semanticDiff, + workspaceSummary: '- Изменено **название** дашборда.\n- Разрешён параметр `allow_dml`.\nraw-html', + workspaceSummaryState: 'ready', + committing: false, + generatingMessage: false, + commitMessage: '', + autoPushAfterCommit: true, + loading: false, + pushProviderLabel: 'gitea', + }); + + const summary = screen.getByTestId('workspace-summary-markdown'); + expect(summary.querySelectorAll('li')).toHaveLength(2); + expect(summary.querySelector('strong')?.textContent).toBe('название'); + expect(summary.querySelector('code')?.textContent).toBe('allow_dml'); + expect(summary.querySelector('img')).toBeNull(); + const technical = screen.getByText('Технический YAML diff').closest('details'); + expect(technical?.hasAttribute('open')).toBe(false); + expect(container.textContent).toContain('Добавьте описание версии, чтобы сохранить её.'); + expect(screen.getByRole('button', { name: 'Сохранить версию' }).hasAttribute('disabled')).toBe(true); + }); + + it('keeps saving available during a long-running summary request', () => { + render(GitWorkspacePanel, { + hasWorkspaceChanges: true, + changedFilesCount: 1, + workspaceStatus: { modified_files: ['dashboards/world.yaml'] }, + workspaceLoading: false, + workspaceDiff: semanticDiff, + workspaceSummaryState: 'loading', + committing: false, + generatingMessage: false, + commitMessage: 'Обновлён дашборд', + autoPushAfterCommit: true, + loading: false, + pushProviderLabel: 'gitea', + }); + + expect(screen.getByText('Агент анализирует изменения…')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Сохранить версию' }).hasAttribute('disabled')).toBe(false); + }); + + it('offers an inline retry when the LLM request fails', async () => { + const onGenerateSummary = vi.fn(); + render(GitWorkspacePanel, { + hasWorkspaceChanges: true, + changedFilesCount: 1, + workspaceStatus: { modified_files: ['dashboards/world.yaml'] }, + workspaceLoading: false, + workspaceDiff: semanticDiff, + workspaceSummaryState: 'error', + workspaceSummaryError: 'Provider timeout', + committing: false, + generatingMessage: false, + commitMessage: '', + autoPushAfterCommit: true, + loading: false, + pushProviderLabel: 'gitea', + onGenerateSummary, + }); + + expect(screen.getByText('Provider timeout')).toBeTruthy(); + await fireEvent.click(screen.getByRole('button', { name: 'Повторить' })); + expect(onGenerateSummary).toHaveBeenCalledOnce(); + }); +}); +// #endregion Test.Git.WorkspacePanel diff --git a/frontend/src/lib/i18n/locales/en/git.json b/frontend/src/lib/i18n/locales/en/git.json index 033582315..26704b096 100644 --- a/frontend/src/lib/i18n/locales/en/git.json +++ b/frontend/src/lib/i18n/locales/en/git.json @@ -441,7 +441,11 @@ "pipeline_no_version": "Version has not been synchronized", "pipeline_dev_ready": "Version is ready for testing", "pipeline_not_deployed": "Version is not deployed", - "pipeline_outdated": "Does not match DEV", + "pipeline_outdated": "Users see a different version", + "pipeline_content_drift": "PROD content is different", + "pipeline_same_revision_different_content": "The Git version is the same, but the environment content differs", + "pipeline_sync_required": "Version synchronization is required", + "pipeline_message_resolve_drift": "Publishing overwrites the different PROD content and resolves the drift.", "pipeline_validation_needed": "Validation is required", "pipeline_validated": "Validation passed", "pipeline_live": "Users see this version", @@ -504,10 +508,33 @@ "feature_flow_transfer_confirm": "Changes from this draft will become available in DEV. They will not reach PREPROD or PROD until you start publication.", "feature_flow_transferred": "transferred to DEV", "feature_flow_transfer_failed": "Could not transfer the change to DEV", - "pipeline_preprod_candidate": "Validated version for users", + "pipeline_preprod_candidate": "Publication candidate", + "pipeline_candidate_validated": "validated", + "pipeline_candidate_pending": "awaiting validation", + "pipeline_candidate_author": "Author", + "pipeline_candidate_created": "Created", "pipeline_dev_ahead": "DEV has newer changes", "pipeline_next_action": "Next action", "workspace_pending_title": "There are unsaved changes", "workspace_pending_hint": "{count} files changed. They will not be included in the current publication until you save a new version.", - "workspace_pending_action": "Save new version" + "workspace_pending_action": "Go to saving", + "workspace_save_title": "Save version", + "workspace_save_hint": "Describe the meaning of the changes — this description is shown during validation and publication.", + "workspace_version_description": "Version description", + "workspace_save_button": "Save version", + "workspace_save_options": "Save options", + "workspace_refresh_changes": "Refresh changes", + "workspace_description_required": "Add a version description to save it.", + "workspace_changes_title": "Version changes", + "semantic_key_changes": "Key changes", + "workspace_summary_loading": "The agent is analyzing the changes…", + "workspace_summary_loading_hint": "This may take a few minutes. You can keep working with the version.", + "workspace_summary_ai_notice": "AI generated this explanation from the current diff.", + "workspace_summary_regenerate": "Phrase differently", + "workspace_summary_failed": "Could not describe the changes", + "workspace_summary_failed_hint": "Check the LLM configuration and retry the request.", + "workspace_summary_retry": "Retry", + "workspace_summary_generate": "Describe changes with AI", + "technical_yaml_preview": "Technical YAML diff", + "open_raw_diff": "Open text diff" } diff --git a/frontend/src/lib/i18n/locales/ru/git.json b/frontend/src/lib/i18n/locales/ru/git.json index a9922e5f7..321a0bac6 100644 --- a/frontend/src/lib/i18n/locales/ru/git.json +++ b/frontend/src/lib/i18n/locales/ru/git.json @@ -441,7 +441,11 @@ "pipeline_no_version": "Версия не синхронизирована", "pipeline_dev_ready": "Версия готова к тестированию", "pipeline_not_deployed": "Версия не развёрнута", - "pipeline_outdated": "Не соответствует DEV", + "pipeline_outdated": "Пользователи видят другую версию", + "pipeline_content_drift": "Содержимое PROD отличается", + "pipeline_same_revision_different_content": "Git-версия та же, но содержимое окружения отличается", + "pipeline_sync_required": "Требуется синхронизация версии", + "pipeline_message_resolve_drift": "Публикация перезапишет отличающееся содержимое PROD и устранит drift.", "pipeline_validation_needed": "Ожидает проверки", "pipeline_validated": "Проверка пройдена", "pipeline_live": "Пользователи видят эту версию", @@ -504,10 +508,33 @@ "feature_flow_transfer_confirm": "Изменения из черновика станут доступны в DEV. В PREPROD и PROD они не попадут, пока вы не запустите публикацию.", "feature_flow_transferred": "передана в DEV", "feature_flow_transfer_failed": "Не удалось передать доработку в DEV", - "pipeline_preprod_candidate": "Проверенная версия для пользователей", + "pipeline_preprod_candidate": "Кандидат на публикацию", + "pipeline_candidate_validated": "проверен", + "pipeline_candidate_pending": "ожидает проверки", + "pipeline_candidate_author": "Автор", + "pipeline_candidate_created": "Создан", "pipeline_dev_ahead": "В DEV есть более новые изменения", "pipeline_next_action": "Следующее действие", "workspace_pending_title": "Есть несохранённые изменения", "workspace_pending_hint": "{count} файлов изменены. Они не войдут в текущую публикацию, пока вы не сохраните новую версию.", - "workspace_pending_action": "Сохранить новую версию" + "workspace_pending_action": "Перейти к сохранению", + "workspace_save_title": "Сохранить версию", + "workspace_save_hint": "Опишите смысл изменений — это описание увидят при проверке и публикации.", + "workspace_version_description": "Описание версии", + "workspace_save_button": "Сохранить версию", + "workspace_save_options": "Параметры сохранения", + "workspace_refresh_changes": "Обновить изменения", + "workspace_description_required": "Добавьте описание версии, чтобы сохранить её.", + "workspace_changes_title": "Изменения версии", + "semantic_key_changes": "Ключевые изменения", + "workspace_summary_loading": "Агент анализирует изменения…", + "workspace_summary_loading_hint": "Это может занять несколько минут. Можно продолжать работу с версией.", + "workspace_summary_ai_notice": "Описание сформировано AI по текущему diff.", + "workspace_summary_regenerate": "Сформулировать иначе", + "workspace_summary_failed": "Не удалось описать изменения", + "workspace_summary_failed_hint": "Проверьте настройку LLM и повторите запрос.", + "workspace_summary_retry": "Повторить", + "workspace_summary_generate": "Описать изменения с помощью AI", + "technical_yaml_preview": "Технический YAML diff", + "open_raw_diff": "Открыть текстовый diff" } diff --git a/frontend/src/lib/models/GitManagerModel.svelte.ts b/frontend/src/lib/models/GitManagerModel.svelte.ts index 0fbca2c67..646f478c5 100644 --- a/frontend/src/lib/models/GitManagerModel.svelte.ts +++ b/frontend/src/lib/models/GitManagerModel.svelte.ts @@ -18,6 +18,7 @@ // @ACTION loadWorkspace() — Loads workspace status and diff. // @ACTION handleSync() — Synchronizes dashboard state with Git. // @ACTION handleGenerateMessage() — Generates AI commit message from diff. +// @ACTION handleGenerateWorkspaceSummary() — Generates a non-blocking BI explanation of the current diff. // @ACTION handleCommit() — Stages and commits workspace changes. // @ACTION handlePromote(...) — Promotes changes between branches. // @ACTION handlePull() — Pulls from remote. @@ -67,8 +68,10 @@ import { applyGitflowStageDefaults, resolvePushProviderLabel, extractHttpHost, + getSemanticWorkspaceFiles, + filterTechnicalWorkspaceDiff, } from '../../services/git-utils.js'; -import { getT } from '$lib/i18n/index.svelte.js'; +import { getT, locale } from '$lib/i18n/index.svelte.js'; import { log } from '$lib/cot-logger'; // ── Types ───────────────────────────────────────────────────── @@ -275,6 +278,11 @@ export class GitManagerModel { workspaceStatus: WorkspaceStatus | null = $state(null); workspaceDiff: string = $state(''); workspaceLoading: boolean = $state(false); + workspaceSummary: string = $state(''); + workspaceSummaryState: 'idle' | 'loading' | 'ready' | 'error' = $state('idle'); + workspaceSummaryError: string = $state(''); + private _workspaceSummaryDiff: string = ''; + private _workspaceSummaryRequestVersion: number = 0; // ── Pull / Push ───────────────────────────────────────────── isPulling: boolean = $state(false); @@ -356,16 +364,12 @@ export class GitManagerModel { /** True when there are any workspace changes (staged, modified, or untracked). */ hasWorkspaceChanges: boolean = $derived.by(() => { - if (!this.workspaceStatus) return false; - const w = this.workspaceStatus; - return [...(w.staged_files || []), ...(w.modified_files || []), ...(w.untracked_files || [])].length > 0; + return getSemanticWorkspaceFiles(this.workspaceStatus).length > 0; }); /** Total count of changed files (staged + modified + untracked). */ changedFilesCount: number = $derived.by(() => { - if (!this.workspaceStatus) return 0; - const w = this.workspaceStatus; - return [...(w.staged_files || []), ...(w.modified_files || []), ...(w.untracked_files || [])].length; + return getSemanticWorkspaceFiles(this.workspaceStatus).length; }); /** True when the current stage has a valid next promotion target. */ @@ -698,9 +702,10 @@ export class GitManagerModel { const sd: string = await gitService.getDiff(this.dashboardId, null, true, this.resolvedEnvId); const ud: string = await gitService.getDiff(this.dashboardId, null, false, this.resolvedEnvId); this.workspaceStatus = ws; - this.workspaceDiff = [sd, ud].filter(Boolean).join('\n\n'); + this.workspaceDiff = filterTechnicalWorkspaceDiff([sd, ud].filter(Boolean).join('\n\n')); this.currentBranch = ws?.current_branch || this.currentBranch; this.applyPromotionDefaultsForCurrentBranch(); + void this.handleGenerateWorkspaceSummary(); } catch (e: unknown) { this._setGitError(e); } finally { @@ -759,6 +764,73 @@ export class GitManagerModel { } } + // #region Git.ManagerModel.GenerateWorkspaceSummary [C:4] [TYPE Function] [SEMANTICS git,llm,summary,workspace] + // @ingroup Git + // @BRIEF Request an LLM-authored BI explanation without blocking save, sync, or navigation. + // @PRE workspaceDiff is the currently rendered semantic diff. + // @POST Only a response for the still-current diff may transition the summary to ready. + // @SIDE_EFFECT Calls the configured Git LLM endpoint; request may remain active for up to three minutes. + // @INVARIANT A stale response can never replace the summary for a newer workspace diff. + // @UX_STATE loading -> Inline progress remains visible while the rest of the workspace stays interactive. + // @UX_RECOVERY error -> User can retry; force=true deliberately asks the LLM for another formulation. + async handleGenerateWorkspaceSummary(force = false): Promise { + const requestedDiff = this.workspaceDiff; + if (!requestedDiff.trim()) { + this._workspaceSummaryRequestVersion += 1; + this._workspaceSummaryDiff = ''; + this.workspaceSummary = ''; + this.workspaceSummaryError = ''; + this.workspaceSummaryState = 'idle'; + return; + } + if (!force && this._workspaceSummaryDiff === requestedDiff && ['loading', 'ready'].includes(this.workspaceSummaryState)) { + return; + } + + const requestVersion = ++this._workspaceSummaryRequestVersion; + this._workspaceSummaryDiff = requestedDiff; + this.workspaceSummary = ''; + this.workspaceSummaryError = ''; + this.workspaceSummaryState = 'loading'; + log('GitManagerModel.handleGenerateWorkspaceSummary', 'REASON', 'Request BI-facing workspace summary', { + diffChars: requestedDiff.length, + force, + }); + try { + const language = locale.current === 'en' ? 'English' : 'Russian'; + const query = new URLSearchParams({ + purpose: 'summary', + language, + ...(this.resolvedEnvId ? { env_id: String(this.resolvedEnvId) } : {}), + }); + const data: { summary?: string } = await api.postApi( + `/git/repositories/${encodeURIComponent(String(this.dashboardId))}/generate-message?${query.toString()}`, + undefined, + { suppressToast: true, signal: AbortSignal.timeout(180_000) }, + ); + if (requestVersion !== this._workspaceSummaryRequestVersion || requestedDiff !== this.workspaceDiff) return; + const summary = String(data?.summary || '').trim(); + if (!summary) throw new Error('LLM returned an empty change summary'); + this.workspaceSummary = summary; + this.workspaceSummaryState = 'ready'; + log('GitManagerModel.handleGenerateWorkspaceSummary', 'REFLECT', 'BI-facing workspace summary is current', { + summaryChars: summary.length, + }); + } catch (e: unknown) { + if (requestVersion !== this._workspaceSummaryRequestVersion || requestedDiff !== this.workspaceDiff) return; + this.workspaceSummaryError = e instanceof Error ? e.message : 'Summary generation failed'; + this.workspaceSummaryState = 'error'; + log( + 'GitManagerModel.handleGenerateWorkspaceSummary', + 'EXPLORE', + 'BI-facing workspace summary unavailable', + { diffChars: requestedDiff.length }, + this.workspaceSummaryError, + ); + } + } + // #endregion Git.ManagerModel.GenerateWorkspaceSummary + // ── Commit ─────────────────────────────────────────────────── /** diff --git a/frontend/src/lib/models/__tests__/GitManagerModel.test.ts b/frontend/src/lib/models/__tests__/GitManagerModel.test.ts index 370af3acd..667821c43 100644 --- a/frontend/src/lib/models/__tests__/GitManagerModel.test.ts +++ b/frontend/src/lib/models/__tests__/GitManagerModel.test.ts @@ -22,7 +22,7 @@ vi.mock("../../../services/gitService.js", () => ({ })); vi.mock("$lib/api.js", () => ({ api: { getEnvironmentsList: vi.fn(), postApi: vi.fn() } })); vi.mock('$lib/toasts.svelte.js', () => ({ addToast: vi.fn() })); -vi.mock('$lib/i18n/index.svelte.js', () => ({ t: { subscribe: vi.fn() }, _: vi.fn(), getT: vi.fn(() => ({ git: { sync_success: "Synced", commit_success: "Committed", commit_and_push_success: "Commit & Push OK", commit_message_generated: "Generated", pull_success: "Pulled", push_success: "Pushed", init_success: "Init OK", init_validation_error: "Fill all fields", no_servers_configured: "No servers", repo_already_exists: "Already exists" } })) })); +vi.mock('$lib/i18n/index.svelte.js', () => ({ t: { subscribe: vi.fn() }, _: vi.fn(), locale: { current: 'ru' }, getT: vi.fn(() => ({ git: { sync_success: "Synced", commit_success: "Committed", commit_and_push_success: "Commit & Push OK", commit_message_generated: "Generated", pull_success: "Pulled", push_success: "Pushed", init_success: "Init OK", init_validation_error: "Fill all fields", no_servers_configured: "No servers", repo_already_exists: "Already exists" } })) })); vi.mock("$lib/cot-logger", () => ({ log: vi.fn() })); vi.mock("svelte/store", () => ({ get: vi.fn(() => ({ git: {} })) })); @@ -55,7 +55,20 @@ describe("GitManagerModel — L1 invariants (no render)", () => { it("hasWorkspaceChanges true when modified", () => { model.workspaceStatus = makeWs({ modified_files: ["x"] }); expect(model.hasWorkspaceChanges).toBe(true); }); it("hasWorkspaceChanges true when untracked", () => { model.workspaceStatus = makeWs({ untracked_files: ["x"] }); expect(model.hasWorkspaceChanges).toBe(true); }); it("hasWorkspaceChanges false when empty", () => { model.workspaceStatus = makeWs(); expect(model.hasWorkspaceChanges).toBe(false); }); - it("changedFilesCount aggregates all", () => { model.workspaceStatus = makeWs({ staged_files: ["a", "b"], modified_files: ["c"], untracked_files: ["d", "e"] }); expect(model.changedFilesCount).toBe(5); }); + it("changedFilesCount aggregates unique semantic files", () => { model.workspaceStatus = makeWs({ staged_files: ["a", "b"], modified_files: ["b", "c"], untracked_files: ["d", "e"] }); expect(model.changedFilesCount).toBe(5); }); + it("hides integration artifacts from BI workspace counts", () => { + model.workspaceStatus = makeWs({ + staged_files: ["dashboards/sales.yaml", "metadata.yaml"], + modified_files: ["dashboards/sales.yaml", ".superset-tools-fingerprint"], + }); + expect(model.changedFilesCount).toBe(1); + expect(model.hasWorkspaceChanges).toBe(true); + }); + it("does not offer a BI commit for integration-only changes", () => { + model.workspaceStatus = makeWs({ modified_files: ["metadata.yaml", ".superset-tools-fingerprint"] }); + expect(model.changedFilesCount).toBe(0); + expect(model.hasWorkspaceChanges).toBe(false); + }); it("changedFilesCount 0 when null", () => { model.workspaceStatus = null; expect(model.changedFilesCount).toBe(0); }); it("changedFilesCount 0 when empty", () => { model.workspaceStatus = makeWs(); expect(model.changedFilesCount).toBe(0); }); it("resolvedEnvId returns envId", () => { model.envId = "env-456"; expect(model.resolvedEnvId).toBe("env-456"); }); @@ -241,6 +254,43 @@ describe("GitManagerModel — L1 invariants (no render)", () => { }); }); + describe("handleGenerateWorkspaceSummary — asynchronous LLM state", () => { + it("stores a free-form summary returned for the current diff", async () => { + model.workspaceDiff = "diff --git a/dashboards/world.yaml b/dashboards/world.yaml\n-old\n+new"; + vi.mocked(api.postApi).mockResolvedValue({ summary: "Обновлены структура и представление дашборда." }); + + await model.handleGenerateWorkspaceSummary(); + + expect(api.postApi).toHaveBeenCalledWith( + expect.stringContaining("purpose=summary"), + undefined, + expect.objectContaining({ suppressToast: true, signal: expect.any(AbortSignal) }), + ); + expect(model.workspaceSummary).toBe("Обновлены структура и представление дашборда."); + expect(model.workspaceSummaryState).toBe("ready"); + }); + + it("keeps summary failure local and recoverable", async () => { + model.workspaceDiff = "diff --git a/charts/kpi.yaml b/charts/kpi.yaml\n-old\n+new"; + vi.mocked(api.postApi).mockRejectedValue(new Error("Provider timeout")); + + await model.handleGenerateWorkspaceSummary(); + + expect(model.workspaceSummaryState).toBe("error"); + expect(model.workspaceSummaryError).toBe("Provider timeout"); + expect(model.gitError).toBeNull(); + }); + + it("does not call the LLM when the workspace has no diff", async () => { + model.workspaceDiff = ""; + + await model.handleGenerateWorkspaceSummary(); + + expect(api.postApi).not.toHaveBeenCalled(); + expect(model.workspaceSummaryState).toBe("idle"); + }); + }); + describe("checkStatus — guard paths", () => { it("blocks numeric dashboardId", async () => { model.dashboardId = "12345"; await model.checkStatus(); diff --git a/frontend/src/services/__tests__/git-utils.workspace.test.ts b/frontend/src/services/__tests__/git-utils.workspace.test.ts new file mode 100644 index 000000000..140ecec3e --- /dev/null +++ b/frontend/src/services/__tests__/git-utils.workspace.test.ts @@ -0,0 +1,58 @@ +// #region Test.Git.WorkspaceUtils [C:2] [TYPE Module] [SEMANTICS test,git,workspace,bi] +// @BRIEF Technical Superset artifacts never compete with semantic dashboard changes in BI UI. +// @TEST_INVARIANT SemanticFileCount -> duplicate Git states count once and technical files count zero. +// @TEST_INVARIANT SemanticDiff -> technical file chunks are removed while dashboard chunks stay intact. +import { describe, expect, it } from 'vitest'; +import { + filterTechnicalWorkspaceDiff, + getSemanticWorkspaceFiles, + isTechnicalWorkspaceFile, +} from '../git-utils.js'; + +describe('Git workspace BI projection', () => { + it('recognizes only root integration artifacts as technical', () => { + expect(isTechnicalWorkspaceFile('metadata.yaml')).toBe(true); + expect(isTechnicalWorkspaceFile('a/.superset-tools-fingerprint')).toBe(true); + expect(isTechnicalWorkspaceFile('dashboards/metadata.yaml')).toBe(false); + }); + + it('deduplicates semantic files across Git status groups', () => { + expect(getSemanticWorkspaceFiles({ + staged_files: ['dashboards/sales.yaml', 'metadata.yaml'], + modified_files: ['dashboards/sales.yaml', '.superset-tools-fingerprint'], + untracked_files: ['charts/revenue.yaml'], + })).toEqual(['dashboards/sales.yaml', 'charts/revenue.yaml']); + }); + + it('removes technical chunks from a multi-file diff', () => { + const diff = [ + 'diff --git a/.superset-tools-fingerprint b/.superset-tools-fingerprint', + 'index 111..222 100644', + '--- a/.superset-tools-fingerprint', + '+++ b/.superset-tools-fingerprint', + '@@ -1 +1 @@', + '-old', + '+new', + 'diff --git a/dashboards/sales.yaml b/dashboards/sales.yaml', + 'index 333..444 100644', + '--- a/dashboards/sales.yaml', + '+++ b/dashboards/sales.yaml', + '@@ -1 +1 @@', + '-title: Old', + '+title: New', + 'diff --git a/metadata.yaml b/metadata.yaml', + 'index 555..666 100644', + '--- a/metadata.yaml', + '+++ b/metadata.yaml', + '@@ -1 +1 @@', + '-timestamp: old', + '+timestamp: new', + ].join('\n'); + + const result = filterTechnicalWorkspaceDiff(diff); + expect(result).toContain('dashboards/sales.yaml'); + expect(result).not.toContain('.superset-tools-fingerprint'); + expect(result).not.toContain('metadata.yaml'); + }); +}); +// #endregion Test.Git.WorkspaceUtils diff --git a/frontend/src/services/git-utils.ts b/frontend/src/services/git-utils.ts index 3abc7c4a4..78f823803 100644 --- a/frontend/src/services/git-utils.ts +++ b/frontend/src/services/git-utils.ts @@ -114,6 +114,67 @@ export function extractHttpHost(urlValue: string | null | undefined): string { } } +/** Files maintained by the Git/Superset integration rather than by a BI author. */ +const TECHNICAL_WORKSPACE_FILES = new Set([ + '.superset-tools-fingerprint', + 'metadata.yaml', + 'metadata.yml', +]); + +/** Return true when a workspace path is an integration artifact, not a dashboard change. */ +export function isTechnicalWorkspaceFile(pathValue: string | null | undefined): boolean { + const normalized = String(pathValue || '') + .trim() + .replace(/^(?:a|b)\//, '') + .replace(/^\.\//, ''); + return TECHNICAL_WORKSPACE_FILES.has(normalized); +} + +/** + * Return unique BI-facing changed files across staged, modified and untracked sets. + * A path can be present in more than one Git set; it must still count as one file. + */ +export function getSemanticWorkspaceFiles(status: { + staged_files?: string[]; + modified_files?: string[]; + untracked_files?: string[]; +} | null | undefined): string[] { + if (!status) return []; + return Array.from(new Set([ + ...(status.staged_files || []), + ...(status.modified_files || []), + ...(status.untracked_files || []), + ].map((path) => String(path || '').trim()).filter(Boolean))) + .filter((path) => !isTechnicalWorkspaceFile(path)); +} + +/** Remove integration-only file sections from a unified Git diff shown to BI users. */ +export function filterTechnicalWorkspaceDiff(diffValue: string | null | undefined): string { + const diff = String(diffValue || ''); + if (!diff.trim()) return ''; + const header = 'diff --git '; + const starts: number[] = []; + let cursor = 0; + while (cursor < diff.length) { + const index = diff.indexOf(header, cursor); + if (index < 0) break; + starts.push(index); + cursor = index + header.length; + } + if (starts.length === 0) return diff; + + const prefix = diff.slice(0, starts[0]); + const kept = starts.flatMap((start, index) => { + const end = starts[index + 1] ?? diff.length; + const chunk = diff.slice(start, end); + const firstLine = chunk.slice(0, chunk.indexOf('\n') >= 0 ? chunk.indexOf('\n') : chunk.length); + const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(firstLine); + const paths = match ? [match[1], match[2]] : []; + return paths.length > 0 && paths.every(isTechnicalWorkspaceFile) ? [] : [chunk]; + }); + return `${kept.length ? prefix : ''}${kept.join('')}`.trim(); +} + /** * Build deterministic repository name from dashboard title/id. */