feat(git): clarify dashboard release flow

This commit is contained in:
2026-07-13 16:53:35 +03:00
parent 2eca5b514b
commit ed85e0d80a
18 changed files with 825 additions and 140 deletions

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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 }) => {

View File

@@ -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}

View File

@@ -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>

View File

@@ -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)}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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 ───────────────────────────────────────────────────
/**

View File

@@ -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();

View 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

View File

@@ -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.
*/