feat(git): clarify dashboard release flow
This commit is contained in:
@@ -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
|
||||
|
||||
45
backend/src/plugins/git/__tests__/test_llm_extension.py
Normal file
45
backend/src/plugins/git/__tests__/test_llm_extension.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -5,15 +5,25 @@
|
||||
<!-- @RELATION DEPENDS_ON -> [EXT:@humanspeak/svelte-markdown] -->
|
||||
<!-- @UX_STATE Rendered -> Markdown rendered with proper headings, lists, code blocks, links. -->
|
||||
<!-- @UX_STATE Empty -> Empty string renders nothing. -->
|
||||
<!-- @INVARIANT blockHtml=true renders Markdown semantics while refusing raw HTML tags. -->
|
||||
<script lang="ts">
|
||||
import SvelteMarkdown from "@humanspeak/svelte-markdown";
|
||||
import SvelteMarkdown, { buildUnsupportedHTML, defaultRenderers } from "@humanspeak/svelte-markdown";
|
||||
|
||||
let { source = "" } = $props();
|
||||
let { source = "", blockHtml = false } = $props();
|
||||
|
||||
const markdownOnlyRenderers = {
|
||||
...defaultRenderers,
|
||||
html: buildUnsupportedHTML(),
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if source}
|
||||
<div class="markdown-text">
|
||||
<SvelteMarkdown {source} />
|
||||
{#if blockHtml}
|
||||
<SvelteMarkdown {source} renderers={markdownOnlyRenderers} />
|
||||
{:else}
|
||||
<SvelteMarkdown {source} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
<h3 class="text-sm font-semibold text-text">{$t.git?.pipeline_title || 'Путь публикации дашборда'}</h3>
|
||||
<p class="mt-0.5 text-xs text-text-muted">{$t.git?.pipeline_hint || 'Версия проходит разработку, проверку и публикацию по содержимому дашборда.'}</p>
|
||||
</div>
|
||||
{#if action !== 'current' && currentHash && prod?.content_hash}
|
||||
{#if action !== 'current' && currentHash && prod?.content_hash && !prodHasSameGitVersionButDifferentContent}
|
||||
<Button variant="secondary" size="sm" onclick={onCompare}>
|
||||
<Icon name="layers" size={14} class="mr-1" />{$t.git?.pipeline_compare || 'Сравнить с PROD'}
|
||||
</Button>
|
||||
@@ -146,10 +155,18 @@
|
||||
<div>
|
||||
{#if preprod?.commit_hash}
|
||||
<div class={`mb-3 rounded-md border px-3 py-2 text-xs ${preprod.drift_status === 'drifted' ? 'border-destructive-ring bg-destructive-light text-destructive' : preprod.validation_status === 'validated' ? 'border-success/30 bg-success-light text-success' : 'border-warning/30 bg-warning-light text-warning'}`}>
|
||||
<span class="font-semibold">{$t.git?.pipeline_preprod_candidate || 'Проверенная версия для пользователей'}:</span>
|
||||
<span class="ml-1 font-mono">{preprod.commit_hash.slice(0, 12)}</span>
|
||||
<span class="ml-1">· {preprod.validation_status === 'validated' ? 'проверен' : 'ожидает проверки'}</span>
|
||||
{#if !preprodMatchesDev}<span class="ml-1 text-text-muted">· {$t.git?.pipeline_dev_ahead || 'В DEV есть более новые изменения'}</span>{/if}
|
||||
<div class="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
|
||||
<span class="font-semibold">{$t.git?.pipeline_preprod_candidate || 'Кандидат на публикацию'}:</span>
|
||||
{#if candidateCommit?.message}<span class="font-medium text-text">{candidateCommit.message}</span>{/if}
|
||||
<span class="font-mono text-text-muted">{preprod.commit_hash.slice(0, 12)}</span>
|
||||
<span>· {preprod.validation_status === 'validated' ? ($t.git?.pipeline_candidate_validated || 'проверен') : ($t.git?.pipeline_candidate_pending || 'ожидает проверки')}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-x-2 text-text-muted">
|
||||
{#if candidateCommit?.author}<span>{$t.git?.pipeline_candidate_author || 'Автор'}: {candidateCommit.author}</span>{/if}
|
||||
{#if candidateCommit?.timestamp}<span>{$t.git?.pipeline_candidate_created || 'Создан'}: {formatDate(String(candidateCommit.timestamp))}</span>{/if}
|
||||
{#if preprod.source_branch}<span>{$t.git?.candidate_source || 'Источник'}: {preprod.source_branch}</span>{/if}
|
||||
{#if !preprodMatchesDev}<span>· {$t.git?.pipeline_dev_ahead || 'В DEV есть более новые изменения'}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -165,7 +182,9 @@
|
||||
<div class="flex justify-between gap-2"><dt>{$t.git?.pipeline_version || 'Версия'}</dt><dd class="font-mono text-text">{stageVersion(stage)}</dd></div>
|
||||
<div class="flex justify-between gap-2"><dt>{stage === 'dev' ? ($t.git?.pipeline_changed || 'Обновлена') : ($t.git?.pipeline_deployed || 'Развёрнута')}</dt><dd class="text-right text-text">{formatDate(stageDate(stage))}</dd></div>
|
||||
{#if stage !== 'dev' && (stage === 'preprod' ? preprod?.drift_status : prod?.drift_status) === 'drifted'}
|
||||
<div class="text-destructive">Требуется синхронизация версии</div>
|
||||
<div class="text-destructive">{$t.git?.pipeline_sync_required || 'Требуется синхронизация версии'}</div>
|
||||
{:else if stage === 'prod' && prodHasSameGitVersionButDifferentContent}
|
||||
<div class="text-warning">{$t.git?.pipeline_same_revision_different_content || 'Git-версия та же, но содержимое окружения отличается'}</div>
|
||||
{/if}
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
@@ -75,11 +75,15 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="rounded-lg border border-border bg-surface-card p-4" aria-label={$t.git?.feature_flow_title || 'Черновики доработок'}>
|
||||
<section class={`rounded-lg border border-border bg-surface-card ${workingFeatureBranches.length > 0 ? 'p-4' : 'px-4 py-3'}`} aria-label={$t.git?.feature_flow_title || 'Черновики доработок'}>
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-text">{$t.git?.feature_flow_title || 'Черновики доработок'}</h3>
|
||||
<p class="mt-0.5 text-xs text-text-muted">{$t.git?.feature_flow_hint || 'Передайте готовую доработку в общую разработку DEV. Это ещё не отправляет её на проверку или пользователям.'}</p>
|
||||
<p class="mt-0.5 text-xs text-text-muted">
|
||||
{workingFeatureBranches.length > 0
|
||||
? ($t.git?.feature_flow_hint || 'Передайте готовую доработку в общую разработку DEV. Это ещё не отправляет её на проверку или пользователям.')
|
||||
: ($t.git?.feature_flow_no_active || 'Нет отдельных доработок, ожидающих передачи в DEV.')}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded-full bg-surface-muted px-2 py-1 text-xs text-text-muted">{featureBranches.length}</span>
|
||||
@@ -87,13 +91,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if workingFeatureBranches.length === 0}
|
||||
<div class="mt-3 rounded-md border border-dashed border-border bg-surface-page px-3 py-3 text-center text-xs text-text-muted">
|
||||
{archivedFeatureBranches.length > 0
|
||||
? ($t.git?.feature_flow_no_active || 'Нет активных доработок: все черновики уже в DEV.')
|
||||
: ($t.git?.feature_flow_empty || 'Нет отдельных черновиков. Создайте feature-ветку в технических деталях.')}
|
||||
</div>
|
||||
{:else}
|
||||
{#if workingFeatureBranches.length > 0}
|
||||
<div class="mt-3 grid gap-3 xl:grid-cols-[minmax(0,1fr)_17rem]">
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
{#each workingFeatureBranches as branch (branch.name)}
|
||||
|
||||
@@ -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<HTMLTextAreaElement>('#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 @@
|
||||
<h3 class="text-sm font-semibold text-text">{$t.git?.workspace_pending_title || 'Есть несохранённые изменения'}</h3>
|
||||
<p class="mt-0.5 text-xs text-text-muted">{($t.git?.workspace_pending_hint || '{count} файлов изменены. Они не войдут в текущую публикацию, пока вы не сохраните новую версию.').replace('{count}', String(model.changedFilesCount))}</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onclick={() => { model.activeTab = 'workspace'; }}>
|
||||
<Icon name="edit" size={14} class="mr-1" />{$t.git?.workspace_pending_action || 'Сохранить новую версию'}
|
||||
<Button variant="secondary" size="sm" onclick={navigateToVersionSave}>
|
||||
<Icon name="edit" size={14} class="mr-1" />{$t.git?.workspace_pending_action || 'Перейти к сохранению'}
|
||||
</Button>
|
||||
</section>
|
||||
{/if}
|
||||
@@ -342,7 +352,7 @@
|
||||
</div>
|
||||
<div id="git-tab-panel" role="tabpanel" aria-labelledby="git-tab-workspace" class="flex min-h-0 flex-1 flex-col">
|
||||
{#if model.activeTab === 'workspace'}
|
||||
<GitWorkspacePanel {dashboardId} envId={model.resolvedEnvId} commitHistoryKey={model.commitHistoryKey} hasWorkspaceChanges={model.hasWorkspaceChanges} changedFilesCount={model.changedFilesCount} workspaceStatus={model.workspaceStatus} workspaceLoading={model.workspaceLoading} workspaceDiff={model.workspaceDiff} committing={model.committing} generatingMessage={model.generatingMessage} bind:commitMessage={model.commitMessage} bind:autoPushAfterCommit={model.autoPushAfterCommit} loading={model.loading} pushProviderLabel={model.pushProviderLabel} onSync={() => model.handleSync()} onGenerateMessage={() => model.handleGenerateMessage()} onCommit={() => model.handleCommit()} />
|
||||
<GitWorkspacePanel {dashboardId} envId={model.resolvedEnvId} commitHistoryKey={model.commitHistoryKey} hasWorkspaceChanges={model.hasWorkspaceChanges} changedFilesCount={model.changedFilesCount} workspaceStatus={model.workspaceStatus} workspaceLoading={model.workspaceLoading} workspaceDiff={model.workspaceDiff} workspaceSummary={model.workspaceSummary} workspaceSummaryState={model.workspaceSummaryState} workspaceSummaryError={model.workspaceSummaryError} committing={model.committing} generatingMessage={model.generatingMessage} bind:commitMessage={model.commitMessage} bind:autoPushAfterCommit={model.autoPushAfterCommit} loading={model.loading} pushProviderLabel={model.pushProviderLabel} onSync={() => model.handleSync()} onGenerateMessage={() => model.handleGenerateMessage()} onGenerateSummary={() => model.handleGenerateWorkspaceSummary(true)} onCommit={() => model.handleCommit()} />
|
||||
{:else if model.activeTab === 'release'}
|
||||
<GitReleasePanel currentEnvStage={model.currentEnvStage} bind:promoteFromBranch={model.promoteFromBranch} bind:promoteToBranch={model.promoteToBranch} bind:promoteMode={model.promoteMode} bind:promoteReason={model.promoteReason} preferredDeployTargetStage={model.preferredDeployTargetStage} bind:showAdvancedPromote={model.showAdvancedPromote} promoting={model.promoting} onPromote={() => model.handlePromote()} onDeploy={() => model.openDeployModal()} onOpenHistory={() => (model.activeTab = 'workspace')} />
|
||||
{:else}
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
<!-- @PRE hasWorkspaceChanges and workspaceDiff are propagated from GitManager. -->
|
||||
<!-- @UX_STATE Idle -> "Нет изменений" placeholder when !hasWorkspaceChanges. -->
|
||||
<!-- @UX_STATE Changes -> workspaceDiff rendered in chunks as user scrolls. -->
|
||||
<!-- @UX_STATE SummaryLoading -> Agent progress is visible; save and diff controls remain interactive. -->
|
||||
<!-- @UX_STATE SummaryReady -> Free-form LLM explanation is visible before collapsed YAML details. -->
|
||||
<!-- @UX_STATE SummaryError -> Inline failure and retry action replace the explanation. -->
|
||||
<!-- @UX_STATE Loading -> GeneratingMessage spinner, diff-chunks loading indicator. -->
|
||||
<!-- @UX_STATE Error -> Toast on failure. -->
|
||||
<!-- @UX_RECOVERY Scroll down to load more diff chunks; sentinel triggers lazy render. -->
|
||||
<!-- @UX_STATE Error -> Toast on commit-message failure. -->
|
||||
<!-- @UX_RECOVERY Retry LLM summary independently; scroll down to load more diff chunks. -->
|
||||
<!-- @RATIONALE IntersectionObserver-based lazy chunked diff rendering chosen because Git diffs can reach 10K+ lines —
|
||||
rendering the entire diff at once freezes the browser (layout thrashing on syntax-highlighted DOM). Chunk size of
|
||||
100 lines with sentinel-triggered expansion keeps initial render under 16ms frame budget while preserving
|
||||
@@ -24,9 +27,11 @@
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
import { Button, Icon } from "$lib/ui";
|
||||
import MarkdownRenderer from '$lib/components/assistant/MarkdownRenderer.svelte';
|
||||
import * as Diff2Html from 'diff2html';
|
||||
import 'diff2html/bundles/css/diff2html.min.css';
|
||||
import CommitHistory from './CommitHistory.svelte';
|
||||
import { getSemanticWorkspaceFiles } from '../../../services/git-utils.js';
|
||||
|
||||
type WorkspaceStatus = {
|
||||
current_branch?: string;
|
||||
@@ -49,6 +54,9 @@
|
||||
workspaceStatus = null,
|
||||
workspaceLoading,
|
||||
workspaceDiff = '',
|
||||
workspaceSummary = '',
|
||||
workspaceSummaryState = 'idle',
|
||||
workspaceSummaryError = '',
|
||||
committing,
|
||||
generatingMessage,
|
||||
commitMessage = $bindable(),
|
||||
@@ -60,6 +68,7 @@
|
||||
commitHistoryKey = 0,
|
||||
onSync,
|
||||
onGenerateMessage,
|
||||
onGenerateSummary,
|
||||
onCommit,
|
||||
} = $props();
|
||||
|
||||
@@ -78,13 +87,7 @@
|
||||
};
|
||||
|
||||
let changedFiles = $derived.by(() => {
|
||||
const status = workspaceStatus as WorkspaceStatus;
|
||||
if (!status) return [];
|
||||
return [
|
||||
...(status.staged_files || []),
|
||||
...(status.modified_files || []),
|
||||
...(status.untracked_files || []),
|
||||
].filter((file, index, files) => files.indexOf(file) === index);
|
||||
return getSemanticWorkspaceFiles(workspaceStatus as WorkspaceStatus);
|
||||
});
|
||||
|
||||
let changeSummary = $derived.by(() => {
|
||||
@@ -175,7 +178,7 @@
|
||||
try {
|
||||
return Diff2Html.html(visible, {
|
||||
outputFormat: diffViewMode,
|
||||
drawFileList: true,
|
||||
drawFileList: false,
|
||||
matching: 'lines',
|
||||
highlight: true,
|
||||
});
|
||||
@@ -219,11 +222,24 @@
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row">
|
||||
<!-- Left sidebar: commit controls (sticky, no scroll) -->
|
||||
<!-- Left sidebar: sequential save controls -->
|
||||
<div class="w-full shrink-0 space-y-4 lg:w-80 xl:w-96">
|
||||
<!-- Save version button — FIRST, prominent -->
|
||||
<div class="rounded-lg border border-border bg-surface-card p-4 shadow-sm">
|
||||
<div class="mb-3 flex items-center gap-2 rounded-lg bg-surface-page px-3 py-2 text-sm text-text-muted">
|
||||
<div id="git-workspace-save" class="rounded-lg border border-border bg-surface-card p-4 shadow-sm" tabindex="-1">
|
||||
<div class="mb-3">
|
||||
<h3 class="text-sm font-semibold text-text">{$t.git?.workspace_save_title || 'Сохранить версию'}</h3>
|
||||
<p class="mt-0.5 text-xs leading-4 text-text-muted">{$t.git?.workspace_save_hint || 'Опишите смысл изменений — это описание увидят при проверке и публикации.'}</p>
|
||||
</div>
|
||||
<label for="git-commit-message" class="mb-1.5 block text-xs font-medium text-text">{$t.git?.workspace_version_description || 'Описание версии'}</label>
|
||||
<textarea
|
||||
id="git-commit-message"
|
||||
bind:value={commitMessage}
|
||||
class={`h-28 w-full resize-none rounded-lg border border-border p-3 text-sm outline-none transition-colors focus:border-primary-ring focus:ring-2 focus:ring-primary-ring ${generatingMessage ? 'animate-pulse bg-surface-page' : 'bg-surface-card'}`}
|
||||
placeholder={$t.git?.describe_changes || 'Что изменилось для пользователей дашборда?'}
|
||||
></textarea>
|
||||
{#if hasWorkspaceChanges && !commitMessage.trim()}
|
||||
<p class="mt-1.5 text-xs text-warning" role="status">{$t.git?.workspace_description_required || 'Добавьте описание версии, чтобы сохранить её.'}</p>
|
||||
{/if}
|
||||
<div class="my-3 flex items-center gap-2 rounded-lg bg-surface-page px-3 py-2 text-sm text-text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="h-4 w-4 text-text-subtle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{$t.git?.files_with_changes || 'Файлов с изменениями:'} <strong class="text-text">{changedFilesCount}</strong>
|
||||
</div>
|
||||
@@ -236,23 +252,16 @@
|
||||
size="lg"
|
||||
>
|
||||
<Icon name="check" size={16} class="-ml-1 mr-1.5" strokeWidth={2} />
|
||||
{$t.git?.commit_button || 'Создать коммит'}
|
||||
{$t.git?.workspace_save_button || 'Сохранить версию'}
|
||||
</Button>
|
||||
|
||||
<label class="mt-3 flex items-center gap-2.5 rounded-md border border-border bg-surface-page px-3 py-2 text-xs text-text-muted transition-colors hover:bg-surface-muted">
|
||||
<input type="checkbox" bind:checked={autoPushAfterCommit} class="h-4 w-4 rounded border-border-strong text-primary focus:ring-primary-ring" />
|
||||
<span>{$t.git?.auto_push_after_commit || 'Сделать push после commit в'} <strong class="font-medium text-text">{pushProviderLabel}</strong></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Commit message -->
|
||||
<div class="rounded-lg border border-border bg-surface-card p-4 shadow-sm">
|
||||
<h3 class="mb-3 text-sm font-semibold text-text">{$t.git?.commit_message || 'Сообщение коммита'}</h3>
|
||||
<textarea
|
||||
bind:value={commitMessage}
|
||||
class={`h-32 w-full resize-none rounded-lg border border-border p-3 text-sm outline-none transition-colors focus:border-primary-ring focus:ring-2 focus:ring-primary-ring ${generatingMessage ? 'animate-pulse bg-surface-page' : 'bg-surface-card'}`}
|
||||
placeholder={$t.git?.describe_changes || 'Опишите изменения...'}
|
||||
></textarea>
|
||||
<details class="mt-3 rounded-md border border-border bg-surface-page text-xs text-text-muted">
|
||||
<summary class="cursor-pointer px-3 py-2 font-medium">{$t.git?.workspace_save_options || 'Параметры сохранения'}</summary>
|
||||
<label class="flex items-center gap-2.5 border-t border-border px-3 py-2 transition-colors hover:bg-surface-muted">
|
||||
<input type="checkbox" bind:checked={autoPushAfterCommit} class="h-4 w-4 rounded border-border-strong text-primary focus:ring-primary-ring" />
|
||||
<span>{$t.git?.auto_push_after_commit || 'Сделать push после commit в'} <strong class="font-medium text-text">{pushProviderLabel}</strong></span>
|
||||
</label>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Compact inline actions: AI generate + Sync -->
|
||||
@@ -276,7 +285,7 @@
|
||||
class="flex-1"
|
||||
>
|
||||
<Icon name="refresh" size={14} strokeWidth={2} />
|
||||
{$t.git?.sync_compact || '🔄 Sync'}
|
||||
{$t.git?.workspace_refresh_changes || 'Обновить изменения'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -286,48 +295,15 @@
|
||||
<div class="flex items-center justify-between border-b border-border bg-surface-page px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="h-4 w-4 text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"/></svg>
|
||||
<span class="text-sm font-semibold text-text">{$t.git?.diff_title || 'Diff (изменения)'}</span>
|
||||
<span class="text-sm font-semibold text-text">{$t.git?.workspace_changes_title || 'Изменения версии'}</span>
|
||||
</div>
|
||||
{#if hasWorkspaceChanges}
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-primary-light px-2.5 py-0.5 text-xs font-medium text-primary ring-1 ring-inset ring-primary-ring">
|
||||
{($t.git?.files_count || '{count} файлов').replace('{count}', String(changedFilesCount))}
|
||||
{#if totalChunks > 0}
|
||||
<span class="text-primary">· {renderedChunkCount}/{totalChunks}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto bg-surface-card p-4" id="diff-scroll-container" bind:this={diffContainerEl}>
|
||||
{#if hasWorkspaceChanges && (workspaceDiff || changeSummary.length > 0)}
|
||||
<div class="mb-3 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium text-primary hover:text-primary-hover cursor-pointer focus-visible:ring-2 focus-visible:ring-primary-ring rounded"
|
||||
onclick={scrollToRawDiff}
|
||||
>
|
||||
{$t.git?.skip_to_raw_diff || 'Skip to raw diff'}
|
||||
</button>
|
||||
<div class="flex items-center gap-1 rounded-md border border-border bg-surface-page p-0.5 text-xs">
|
||||
<span class="px-2 text-text-muted">{$t.git?.diff_view_mode || 'Diff mode'}:</span>
|
||||
<button
|
||||
type="button"
|
||||
class={`rounded px-2 py-1 font-medium transition-colors focus-visible:ring-2 focus-visible:ring-primary-ring ${diffViewMode === 'line-by-line' ? 'bg-surface-card text-text shadow-sm' : 'text-text-muted hover:text-text'}`}
|
||||
onclick={() => (diffViewMode = 'line-by-line')}
|
||||
aria-pressed={diffViewMode === 'line-by-line'}
|
||||
>
|
||||
{$t.git?.diff_view_line_by_line || 'Line-by-line'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`rounded px-2 py-1 font-medium transition-colors focus-visible:ring-2 focus-visible:ring-primary-ring ${diffViewMode === 'side-by-side' ? 'bg-surface-card text-text shadow-sm' : 'text-text-muted hover:text-text'}`}
|
||||
onclick={() => (diffViewMode = 'side-by-side')}
|
||||
aria-pressed={diffViewMode === 'side-by-side'}
|
||||
>
|
||||
{$t.git?.diff_view_side_by_side || 'Side-by-side'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if hasWorkspaceChanges && changeSummary.length > 0}
|
||||
<div class="mb-4 rounded border border-border bg-surface-page p-2">
|
||||
<div class="mb-1 text-xs font-medium text-text-muted">{$t.git?.semantic_summary || 'Change summary'}</div>
|
||||
@@ -340,24 +316,52 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if hasWorkspaceChanges && workspaceDiff}
|
||||
<details id="git-raw-diff" bind:this={rawDiffDetailsEl} class="mb-4 rounded-lg border border-border bg-surface-page">
|
||||
<summary class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm font-semibold text-text">
|
||||
<Icon name="code" size={16} class="text-text-muted" strokeWidth={2} />
|
||||
{$t.git?.advanced_raw_diff_title || $t.git?.raw_diff_title || 'Advanced: raw YAML diff'}
|
||||
</summary>
|
||||
<div class="border-t border-border p-3">
|
||||
<input
|
||||
bind:value={rawDiffSearch}
|
||||
class="mb-3 h-9 w-full rounded-md border border-border-strong bg-surface-card px-3 text-sm text-text outline-none focus:border-primary-ring focus:ring-2 focus:ring-primary-ring"
|
||||
placeholder={$t.git?.raw_diff_search || 'Search raw diff...'}
|
||||
/>
|
||||
<pre class="max-h-80 overflow-auto rounded-md border border-border bg-surface-card p-3 text-xs leading-relaxed text-text"><code>{rawDiffLines.join('\n')}</code></pre>
|
||||
{#if rawDiffLines.length >= 600}
|
||||
<div class="mt-2 text-xs text-text-muted">{$t.git?.raw_diff_limited || 'Showing first 600 matching lines.'}</div>
|
||||
{#if hasWorkspaceChanges}
|
||||
<section
|
||||
class="mb-4 rounded-lg border border-primary/20 bg-primary-light p-3"
|
||||
aria-label={$t.git?.semantic_key_changes || 'Ключевые изменения'}
|
||||
aria-busy={workspaceSummaryState === 'loading'}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-text-muted">{$t.git?.semantic_key_changes || 'Ключевые изменения'}</h4>
|
||||
{#if workspaceSummaryState === 'ready'}
|
||||
<Button variant="ghost" size="sm" class="gap-1" onclick={onGenerateSummary}>
|
||||
<Icon name="refresh" size={13} strokeWidth={2} />
|
||||
{$t.git?.workspace_summary_regenerate || 'Сформулировать иначе'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{#if workspaceSummaryState === 'loading'}
|
||||
<div class="mt-2 flex items-start gap-2 text-sm text-text-muted" role="status" aria-live="polite">
|
||||
<span class="mt-0.5 h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-primary/30 border-t-primary"></span>
|
||||
<div>
|
||||
<p class="font-medium text-text">{$t.git?.workspace_summary_loading || 'Агент анализирует изменения…'}</p>
|
||||
<p class="mt-0.5 text-xs">{$t.git?.workspace_summary_loading_hint || 'Это может занять несколько минут. Можно продолжать работу с версией.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if workspaceSummaryState === 'ready' && workspaceSummary}
|
||||
<div class="mt-2" data-testid="workspace-summary-markdown">
|
||||
<MarkdownRenderer source={workspaceSummary} blockHtml />
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-text-muted">{$t.git?.workspace_summary_ai_notice || 'Описание сформировано AI по текущему diff.'}</p>
|
||||
{:else if workspaceSummaryState === 'error'}
|
||||
<div class="mt-2 flex flex-wrap items-center justify-between gap-2" role="alert">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-warning">{$t.git?.workspace_summary_failed || 'Не удалось описать изменения'}</p>
|
||||
<p class="mt-0.5 text-xs text-text-muted">{workspaceSummaryError || $t.git?.workspace_summary_failed_hint || 'Проверьте настройку LLM и повторите запрос.'}</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" class="gap-1" onclick={onGenerateSummary}>
|
||||
<Icon name="refresh" size={13} strokeWidth={2} />
|
||||
{$t.git?.workspace_summary_retry || 'Повторить'}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Button variant="secondary" size="sm" class="mt-2" onclick={onGenerateSummary}>
|
||||
{$t.git?.workspace_summary_generate || 'Описать изменения с помощью AI'}
|
||||
</Button>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{#if workspaceLoading}
|
||||
<div class="space-y-3">
|
||||
@@ -371,17 +375,68 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else if hasWorkspaceChanges && renderedHtml}
|
||||
<div
|
||||
class="diff-view"
|
||||
role="region"
|
||||
aria-label={$t.git?.diff_region_label || 'Changes preview'}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html renderedHtml}
|
||||
</div>
|
||||
<p class="sr-only">{$t.git?.diff_rendered_hidden_hint || 'Visual diff is hidden from screen reader — use the raw diff below.'}</p>
|
||||
<!-- Sentinel for IntersectionObserver — triggers next chunk load -->
|
||||
<details class="rounded-lg border border-border bg-surface-page">
|
||||
<summary class="flex cursor-pointer items-center gap-2 px-3 py-2.5 text-sm font-semibold text-text">
|
||||
<Icon name="code" size={16} class="text-text-muted" strokeWidth={2} />
|
||||
{$t.git?.technical_yaml_preview || 'Технический YAML diff'}
|
||||
</summary>
|
||||
<div class="border-t border-border bg-surface-card p-3">
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer rounded text-xs font-medium text-primary hover:text-primary-hover focus-visible:ring-2 focus-visible:ring-primary-ring"
|
||||
onclick={scrollToRawDiff}
|
||||
>
|
||||
{$t.git?.open_raw_diff || 'Открыть текстовый diff'}
|
||||
</button>
|
||||
<div class="flex items-center gap-1 rounded-md border border-border bg-surface-page p-0.5 text-xs">
|
||||
<span class="px-2 text-text-muted">{$t.git?.diff_view_mode || 'Режим'}:</span>
|
||||
<button
|
||||
type="button"
|
||||
class={`rounded px-2 py-1 font-medium transition-colors focus-visible:ring-2 focus-visible:ring-primary-ring ${diffViewMode === 'line-by-line' ? 'bg-surface-card text-text shadow-sm' : 'text-text-muted hover:text-text'}`}
|
||||
onclick={() => (diffViewMode = 'line-by-line')}
|
||||
aria-pressed={diffViewMode === 'line-by-line'}
|
||||
>
|
||||
{$t.git?.diff_view_line_by_line || 'Одна колонка'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`rounded px-2 py-1 font-medium transition-colors focus-visible:ring-2 focus-visible:ring-primary-ring ${diffViewMode === 'side-by-side' ? 'bg-surface-card text-text shadow-sm' : 'text-text-muted hover:text-text'}`}
|
||||
onclick={() => (diffViewMode = 'side-by-side')}
|
||||
aria-pressed={diffViewMode === 'side-by-side'}
|
||||
>
|
||||
{$t.git?.diff_view_side_by_side || 'Две колонки'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<details id="git-raw-diff" bind:this={rawDiffDetailsEl} class="mb-3 rounded-lg border border-border bg-surface-page">
|
||||
<summary class="flex cursor-pointer items-center gap-2 px-3 py-2 text-xs font-medium text-text">
|
||||
<Icon name="code" size={14} class="text-text-muted" strokeWidth={2} />
|
||||
{$t.git?.raw_diff_title || 'Текстовый YAML diff'}
|
||||
</summary>
|
||||
<div class="border-t border-border p-3">
|
||||
<input
|
||||
bind:value={rawDiffSearch}
|
||||
class="mb-3 h-9 w-full rounded-md border border-border-strong bg-surface-card px-3 text-sm text-text outline-none focus:border-primary-ring focus:ring-2 focus:ring-primary-ring"
|
||||
placeholder={$t.git?.raw_diff_search || 'Поиск по diff...'}
|
||||
/>
|
||||
<pre class="max-h-80 overflow-auto rounded-md border border-border bg-surface-card p-3 text-xs leading-relaxed text-text"><code>{rawDiffLines.join('\n')}</code></pre>
|
||||
{#if rawDiffLines.length >= 600}
|
||||
<div class="mt-2 text-xs text-text-muted">{$t.git?.raw_diff_limited || 'Показаны первые 600 подходящих строк.'}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
<div
|
||||
class="diff-view"
|
||||
role="region"
|
||||
aria-label={$t.git?.diff_region_label || 'Changes preview'}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html renderedHtml}
|
||||
</div>
|
||||
<p class="sr-only">{$t.git?.diff_rendered_hidden_hint || 'Visual diff is hidden from screen reader — use the raw diff below.'}</p>
|
||||
<!-- Sentinel for IntersectionObserver — triggers next chunk load -->
|
||||
{#if hasMoreChunks}
|
||||
<div
|
||||
bind:this={sentinelEl}
|
||||
@@ -395,11 +450,11 @@
|
||||
<span>{$t.git?.diff_loading || 'Загрузка изменений...'}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="cursor-pointer text-primary hover:text-primary" onclick={() => {
|
||||
<button type="button" class="cursor-pointer text-primary hover:text-primary" onclick={() => {
|
||||
renderedChunkCount = Math.min(renderedChunkCount + CHUNKS_PER_PAGE, totalChunks);
|
||||
}}>
|
||||
{$t.git?.diff_show_more?.replace('{count}', totalChunks - renderedChunkCount) || `Показать ещё (${totalChunks - renderedChunkCount} файлов)`}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if totalChunks > 0}
|
||||
@@ -407,6 +462,8 @@
|
||||
{$t.git?.diff_all_shown?.replace('{count}', totalChunks) || `Показаны все изменения (${totalChunks} файлов)`}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
{:else if hasWorkspaceChanges}
|
||||
<div class="flex h-full flex-col items-center justify-center gap-3 text-sm text-text-subtle">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-text-subtle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"/></svg>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown> }) => 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`.\n<img src="/tracking.gif" alt="raw-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
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
58
frontend/src/services/__tests__/git-utils.workspace.test.ts
Normal file
58
frontend/src/services/__tests__/git-utils.workspace.test.ts
Normal file
@@ -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
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user