This commit is contained in:
2026-05-26 18:15:37 +03:00
parent 0b6f61623e
commit ee08e2f3dd
15 changed files with 13702 additions and 7702 deletions

File diff suppressed because one or more lines are too long

View File

@@ -158,10 +158,10 @@ async def get_repository_status(
_=Depends(has_permission("plugin:git", "EXECUTE")), _=Depends(has_permission("plugin:git", "EXECUTE")),
): ):
with belief_scope("get_repository_status"): with belief_scope("get_repository_status"):
from . import _resolve_dashboard_id_from_ref_async from . import _resolve_dashboard_id_from_ref
try: try:
dashboard_id = await _resolve_dashboard_id_from_ref_async( dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id dashboard_ref, config_manager, env_id
) )
return _resolve_repository_status(dashboard_id) return _resolve_repository_status(dashboard_id)

View File

@@ -232,8 +232,8 @@ class GitPlugin(PluginBase):
shutil.rmtree(backup_dir, ignore_errors=True) shutil.rmtree(backup_dir, ignore_errors=True)
try: try:
repo.git.add(A=True) repo.git.add(A=True, force=True)
app_logger.reason("Changes staged in git", extra={"src": "_handle_sync"}) app_logger.reason("Changes staged in git (force)", extra={"src": "_handle_sync"})
except Exception as ge: except Exception as ge:
app_logger.explore(f"Failed to stage changes: {ge}", extra={"src": "_handle_sync"}) app_logger.explore(f"Failed to stage changes: {ge}", extra={"src": "_handle_sync"})

View File

@@ -130,9 +130,9 @@ class GitServiceGiteaMixin:
# endregion list_gitea_repositories # endregion list_gitea_repositories
# region create_gitea_repository [TYPE Function] # region create_gitea_repository [TYPE Function]
# @PURPOSE: Create repository in Gitea for authenticated user. # @PURPOSE: Create repository in Gitea for authenticated user. Idempotent — returns existing repo if name already taken.
# @PRE name is non-empty and PAT has repo creation permission. # @PRE name is non-empty and PAT has repo creation permission.
# @POST Returns created repository payload. # @POST Returns created or existing repository payload.
# @RETURN dict # @RETURN dict
async def create_gitea_repository( async def create_gitea_repository(
self, server_url: str, pat: str, name: str, private: bool = True, self, server_url: str, pat: str, name: str, private: bool = True,
@@ -143,7 +143,19 @@ class GitServiceGiteaMixin:
payload["description"] = description payload["description"] = description
if default_branch: if default_branch:
payload["default_branch"] = default_branch payload["default_branch"] = default_branch
created = await self._gitea_request("POST", server_url, pat, "/user/repos", payload=payload) try:
created = await self._gitea_request("POST", server_url, pat, "/user/repos", payload=payload)
except HTTPException as exc:
if exc.status_code == 409:
logger.reason(
f"Repository '{name}' already exists on Gitea, fetching existing repo",
extra={"src": "create_gitea_repository"},
)
username = await self.get_gitea_current_user(server_url, pat)
existing = await self._gitea_request("GET", server_url, pat, f"/repos/{username}/{name}")
if isinstance(existing, dict):
return existing
raise
if not isinstance(created, dict): if not isinstance(created, dict):
raise HTTPException(status_code=500, detail="Unexpected Gitea response while creating repository") raise HTTPException(status_code=500, detail="Unexpected Gitea response while creating repository")
return created return created

View File

@@ -35,6 +35,7 @@
"vitest": "^4.0.18" "vitest": "^4.0.18"
}, },
"dependencies": { "dependencies": {
"date-fns": "^4.1.0" "date-fns": "^4.1.0",
"diff2html": "^3.4.56"
} }
} }

View File

@@ -343,6 +343,7 @@
{#if showGitManager && gitDashboardId} {#if showGitManager && gitDashboardId}
<GitManager <GitManager
dashboardId={gitDashboardId} dashboardId={gitDashboardId}
envId={environmentId}
dashboardTitle={gitDashboardTitle} dashboardTitle={gitDashboardTitle}
bind:show={showGitManager} bind:show={showGitManager}
/> />

View File

@@ -1,58 +1,44 @@
<!-- #region BranchSelector [C:3] [TYPE Component] [SEMANTICS git, branch, checkout, create, selector] --> <!-- #region BranchSelector [C:3] [TYPE Component] [SEMANTICS git, branch, checkout, create, selector] -->
<!-- @BRIEF Component component: components/git/BranchSelector.svelte --> <!-- @BRIEF UI for selecting and creating Git branches for a dashboard repository. -->
<!-- @LAYER UI --> <!-- @LAYER UI -->
<!-- <!-- @RELATION CALLS -> [EXT:frontend:gitService.getBranches] -->
@SEMANTICS: git, branch, selection, checkout <!-- @RELATION CALLS -> [EXT:frontend:gitService.checkoutBranch] -->
@PURPOSE: UI для выбора и создания веток Git. <!-- @RELATION CALLS -> [EXT:frontend:gitService.createBranch] -->
@LAYER Component <!-- @RELATION BINDS_TO -> [onchange] -->
@RELATION CALLS -> [gitService.getBranches] <!-- @PRE dashboardId is a valid dashboard slug; envId required when ref is slug. -->
@RELATION CALLS -> [GitServiceBranch] <!-- @UX_STATE Loading -> Select disabled, spinner. -->
@RELATION CALLS -> [gitService.createBranch] <!-- @UX_STATE Loaded -> Branch dropdown enabled. -->
@RELATION BINDS_TO -> [onchange] <!-- @UX_STATE CreateMode -> Inline input for new branch name. -->
-->
<script> <script>
// [SECTION: IMPORTS]
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { gitService } from '../../services/gitService'; import { gitService } from '../../services/gitService';
import { addToast as toast } from '../../lib/toasts.js'; import { addToast as toast } from '../../lib/toasts.js';
import { t } from '../../lib/i18n'; import { t } from '../../lib/i18n';
import { Button, Select, Input } from '../../lib/ui'; import { Button, Select, Input } from '../../lib/ui';
// [/SECTION]
// [SECTION: PROPS] // #region props [C:2] [TYPE Block]
let { // @PRE dashboardId is non-empty slug; envId resolved via GitManager fallback chain.
dashboardId, let {
envId = null, dashboardId,
currentBranch = $bindable('main'), envId = null,
onchange = () => {}, currentBranch = $bindable('main'),
} = $props(); onchange = () => {},
} = $props();
// #endregion props
// [/SECTION] // #region state [C:1] [TYPE Block]
// [SECTION: STATE]
let branches = $state([]); let branches = $state([]);
let loading = $state(false); let loading = $state(false);
let showCreate = $state(false); let showCreate = $state(false);
let newBranchName = $state(''); let newBranchName = $state('');
// [/SECTION] // #endregion state
// #region onMount:Function [TYPE Function]
/**
* @purpose Load branches when component is mounted.
* @pre Component is initialized.
* @post loadBranches is called.
*/
onMount(async () => {
await loadBranches();
});
// #endregion onMount:Function
// #region loadBranches:Function [TYPE Function] // #region loadBranches [C:2] [TYPE Function]
/** // @BRIEF Load branch list from backend.
* @purpose Загружает список веток для дашборда. // @PRE dashboardId is non-empty; envId required when ref is slug.
* @pre dashboardId is provided. // @POST branches array populated with BranchSchema objects.
* @post branches обновлен. // @SIDE_EFFECT GETs /git/repositories/{ref}/branches.
*/
async function loadBranches() { async function loadBranches() {
console.log(`[EXT:frontend:BranchSelector][Action] Loading branches for dashboard ${dashboardId}`); console.log(`[EXT:frontend:BranchSelector][Action] Loading branches for dashboard ${dashboardId}`);
loading = true; loading = true;
@@ -66,25 +52,22 @@
loading = false; loading = false;
} }
} }
// #endregion loadBranches:Function // #endregion loadBranches
// #region handleSelect:Function [TYPE Function] // #region handleSelect [C:1] [TYPE Function]
/** // @BRIEF Handle branch selection from dropdown.
* @purpose Handles branch selection from dropdown. // @PRE event contains target.value with branch name.
* @pre event contains branch name. // @POST handleCheckout called with selected branch name.
* @post handleCheckout is called with selected branch.
*/
function handleSelect(event) { function handleSelect(event) {
handleCheckout(event.target.value); handleCheckout(event.target.value);
} }
// #endregion handleSelect:Function // #endregion handleSelect
// #region handleCheckout:Function [TYPE Function] // #region handleCheckout [C:2] [TYPE Function]
/** // @BRIEF Switch repository to selected branch.
* @purpose Переключает текущую ветку. // @PRE branchName is non-empty; envId required when ref is slug.
* @param {string} branchName - Имя ветки. // @POST currentBranch updated; onchange callback fired.
* @post currentBranch обновлен, родительский callback вызван. // @SIDE_EFFECT POSTs to /git/repositories/{ref}/checkout.
*/
async function handleCheckout(branchName) { async function handleCheckout(branchName) {
console.log(`[EXT:frontend:BranchSelector][Action] Checking out branch ${branchName}`); console.log(`[EXT:frontend:BranchSelector][Action] Checking out branch ${branchName}`);
try { try {
@@ -98,14 +81,13 @@
toast(e.message, 'error'); toast(e.message, 'error');
} }
} }
// #endregion handleCheckout:Function // #endregion handleCheckout
// #region handleCreate:Function [TYPE Function] // #region handleCreate [C:2] [TYPE Function]
/** // @BRIEF Create a new branch from current branch.
* @purpose Создает новую ветку. // @PRE newBranchName is non-empty; envId required when ref is slug.
* @pre newBranchName is not empty. // @POST New branch created; branch list reloaded; create mode closed.
* @post Новая ветка создана и загружена; showCreate reset. // @SIDE_EFFECT POSTs to /git/repositories/{ref}/branches.
*/
async function handleCreate() { async function handleCreate() {
if (!newBranchName) return; if (!newBranchName) return;
console.log(`[EXT:frontend:BranchSelector][Action] Creating branch ${newBranchName} from ${currentBranch}`); console.log(`[EXT:frontend:BranchSelector][Action] Creating branch ${newBranchName} from ${currentBranch}`);
@@ -121,7 +103,11 @@
toast(e.message, 'error'); toast(e.message, 'error');
} }
} }
// #endregion handleCreate:Function // #endregion handleCreate
onMount(async () => {
await loadBranches();
});
</script> </script>
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->

View File

@@ -92,10 +92,13 @@
const configHost = $derived(extractHttpHost(repositoryConfigUrl)); const configHost = $derived(extractHttpHost(repositoryConfigUrl));
const hasOriginConfigMismatch = $derived(originHost && configHost && originHost !== configHost); const hasOriginConfigMismatch = $derived(originHost && configHost && originHost !== configHost);
// ── Resolve envId with localStorage fallback ──────────────
let resolvedEnvId = $derived(resolveCurrentEnvironmentId(envId));
// ── State proxy for composable ──────────────────────────── // ── State proxy for composable ────────────────────────────
function getState() { function getState() {
return { return {
dashboardId, envId, dashboardTitle, toast, t, dashboardId, envId: resolvedEnvId, dashboardTitle, toast, t,
currentBranch, showDeployModal, loading, initialized, checkingStatus, currentBranch, showDeployModal, loading, initialized, checkingStatus,
configs, selectedConfigId, remoteUrl, creatingRemoteRepo, configs, selectedConfigId, remoteUrl, creatingRemoteRepo,
activeTab, showAdvancedPromote, promoting, promoteFromBranch, promoteToBranch, promoteMode, promoteReason, activeTab, showAdvancedPromote, promoting, promoteFromBranch, promoteToBranch, promoteMode, promoteReason,
@@ -170,7 +173,7 @@
await Promise.all([h.checkStatus(), h.loadCurrentEnvironmentStage()]); await Promise.all([h.checkStatus(), h.loadCurrentEnvironmentStage()]);
if (initialized) { if (initialized) {
try { try {
const binding = await gitService.getRepositoryBinding(dashboardId, envId); const binding = await gitService.getRepositoryBinding(dashboardId, resolvedEnvId);
repositoryProvider = binding?.provider || ''; repositoryProvider = binding?.provider || '';
repositoryBindingRemoteUrl = binding?.remote_url || ''; repositoryBindingRemoteUrl = binding?.remote_url || '';
if (binding?.config_id) selectedConfigId = String(binding.config_id); if (binding?.config_id) selectedConfigId = String(binding.config_id);
@@ -203,7 +206,7 @@
<span class={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold ${stageBadgeClass(currentEnvStage)}`}>{currentEnvStage || 'DEV'}</span> <span class={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold ${stageBadgeClass(currentEnvStage)}`}>{currentEnvStage || 'DEV'}</span>
<span class="text-xs text-slate-600">Текущая ветка: <strong>{currentBranch}</strong></span> <span class="text-xs text-slate-600">Текущая ветка: <strong>{currentBranch}</strong></span>
</div> </div>
<div class="w-full max-w-sm"><BranchSelector {dashboardId} {envId} bind:currentBranch /></div> <div class="w-full max-w-sm"><BranchSelector {dashboardId} envId={resolvedEnvId} bind:currentBranch /></div>
</div> </div>
<div class="flex flex-wrap items-center gap-2 border-b border-slate-200 pb-2"> <div class="flex flex-wrap items-center gap-2 border-b border-slate-200 pb-2">
<button class={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === 'workspace' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}`} onclick={() => (activeTab = 'workspace')}>📝 Фиксация изменений</button> <button class={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === 'workspace' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}`} onclick={() => (activeTab = 'workspace')}>📝 Фиксация изменений</button>
@@ -211,7 +214,7 @@
<button class={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === 'operations' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}`} onclick={() => (activeTab = 'operations')}>⚙️ Серверные операции</button> <button class={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === 'operations' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}`} onclick={() => (activeTab = 'operations')}>⚙️ Серверные операции</button>
</div> </div>
{#if activeTab === 'workspace'} {#if activeTab === 'workspace'}
<GitWorkspacePanel {hasWorkspaceChanges} {changedFilesCount} {workspaceLoading} {committing} {generatingMessage} bind:commitMessage bind:autoPushAfterCommit {loading} {pushProviderLabel} onSync={h.handleSync} onGenerateMessage={h.handleGenerateMessage} onCommit={h.handleCommit} /> <GitWorkspacePanel {hasWorkspaceChanges} {changedFilesCount} {workspaceLoading} {workspaceDiff} {committing} {generatingMessage} bind:commitMessage bind:autoPushAfterCommit {loading} {pushProviderLabel} onSync={h.handleSync} onGenerateMessage={h.handleGenerateMessage} onCommit={h.handleCommit} />
{:else if activeTab === 'release'} {:else if activeTab === 'release'}
<GitReleasePanel {currentEnvStage} bind:promoteFromBranch bind:promoteToBranch bind:promoteMode bind:promoteReason {preferredDeployTargetStage} bind:showAdvancedPromote {promoting} onPromote={h.handlePromote} /> <GitReleasePanel {currentEnvStage} bind:promoteFromBranch bind:promoteToBranch bind:promoteMode bind:promoteReason {preferredDeployTargetStage} bind:showAdvancedPromote {promoting} onPromote={h.handlePromote} />
{:else} {:else}
@@ -224,5 +227,5 @@
</div> </div>
{/if} {/if}
<ConflictResolver conflicts={mergeConflicts} bind:show={showConflictResolver} onresolve={h.handleResolveConflicts} /> <ConflictResolver conflicts={mergeConflicts} bind:show={showConflictResolver} onresolve={h.handleResolveConflicts} />
<DeploymentModal {dashboardId} {envId} preferredTargetStage={preferredDeployTargetStage} bind:show={showDeployModal} /> <DeploymentModal {dashboardId} envId={resolvedEnvId} preferredTargetStage={preferredDeployTargetStage} bind:show={showDeployModal} />
<!-- #endregion GitManager --> <!-- #endregion GitManager -->

View File

@@ -3,17 +3,22 @@
<!-- @LAYER UI --> <!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [GitUtils] --> <!-- @RELATION DEPENDS_ON -> [GitUtils] -->
<!-- @RELATION CALLS -> [EXT:frontend:gitService] --> <!-- @RELATION CALLS -> [EXT:frontend:gitService] -->
<!-- @UX_STATE Idle -> Commit form with diff viewer. --> <!-- @PRE hasWorkspaceChanges and workspaceDiff are propagated from GitManager. -->
<!-- @UX_STATE Idle -> "Нет изменений" placeholder when !hasWorkspaceChanges. -->
<!-- @UX_STATE Changes -> workspaceDiff rendered when hasWorkspaceChanges && workspaceDiff. -->
<!-- @UX_STATE Loading -> GeneratingMessage spinner, workspace loading. --> <!-- @UX_STATE Loading -> GeneratingMessage spinner, workspace loading. -->
<!-- @UX_STATE Error -> Toast on failure. --> <!-- @UX_STATE Error -> Toast on failure. -->
<script> <script>
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import { Button } from "$lib/ui"; import { Button } from "$lib/ui";
import * as Diff2Html from 'diff2html';
import 'diff2html/bundles/css/diff2html.min.css';
let { let {
hasWorkspaceChanges, hasWorkspaceChanges,
changedFilesCount, changedFilesCount,
workspaceLoading, workspaceLoading,
workspaceDiff = '',
committing, committing,
generatingMessage, generatingMessage,
commitMessage = $bindable(), commitMessage = $bindable(),
@@ -24,6 +29,16 @@
onGenerateMessage, onGenerateMessage,
onCommit, onCommit,
} = $props(); } = $props();
// ── Render diff as HTML via diff2html ──
let diffHtml = $derived(workspaceDiff
? Diff2Html.html(workspaceDiff, {
outputFormat: 'side-by-side',
drawFileList: true,
matching: 'lines',
highlight: true,
})
: '');
</script> </script>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
@@ -85,6 +100,10 @@
<div class="h-4 animate-pulse rounded bg-slate-200"></div> <div class="h-4 animate-pulse rounded bg-slate-200"></div>
{/each} {/each}
</div> </div>
{:else if hasWorkspaceChanges && diffHtml}
<div class="diff-view">{@html diffHtml}</div>
{:else if hasWorkspaceChanges}
<pre class="overflow-x-auto whitespace-pre-wrap font-mono text-xs leading-relaxed text-slate-500">Загрузка diff... (повторите синхронизацию)</pre>
{:else} {:else}
<div class="flex h-full items-center justify-center text-sm text-slate-500"> <div class="flex h-full items-center justify-center text-sm text-slate-500">
Нет изменений для коммита Нет изменений для коммита

View File

@@ -20,8 +20,8 @@ describe('GitManager unfinished merge dialog contract', () => {
expect(utils).toContain("error_code !== 'GIT_UNFINISHED_MERGE'"); expect(utils).toContain("error_code !== 'GIT_UNFINISHED_MERGE'");
expect(handlers).toContain('showUnfinishedMergeDialog: true'); expect(handlers).toContain('showUnfinishedMergeDialog: true');
expect(handlers).toContain('this.openUnfinishedMergeDialogFromError(e)'); expect(handlers).toContain('handlers.openUnfinishedMergeDialogFromError(e)');
expect(handlers).toContain('await this.loadMergeRecoveryState();'); expect(handlers).toContain('await handlers.loadMergeRecoveryState();');
expect(manager).toContain('h.handlePull'); expect(manager).toContain('h.handlePull');
expect(manager).toContain('h.loadMergeRecoveryState'); expect(manager).toContain('h.loadMergeRecoveryState');
}); });

View File

@@ -11,7 +11,14 @@ export function createGitHandlers(getState, setState) {
const s = () => getState(); const s = () => getState();
const u = (patch) => setState(patch); const u = (patch) => setState(patch);
return { // ── Use const reference instead of `this` to avoid context loss in callbacks ──
const handlers = {
// #region loadCurrentEnvironmentStage [C:2] [TYPE Function]
// @BRIEF Resolve current environment stage (DEV/PREPROD/PROD) and apply GitFlow defaults.
// @PRE resolveEnvId() returns a valid environment ID, or handler exits silently.
// @POST currentEnvStage and GitFlow defaults (promoteFromBranch, promoteToBranch) are set.
// @SIDE_EFFECT Fetches /environments from API.
async loadCurrentEnvironmentStage() { async loadCurrentEnvironmentStage() {
const currentEnvId = s().resolveEnvId(); const currentEnvId = s().resolveEnvId();
if (!currentEnvId) return; if (!currentEnvId) return;
@@ -27,7 +34,13 @@ export function createGitHandlers(getState, setState) {
console.error(`[GitManager][Coherence:Failed] Failed to resolve environment stage: ${e.message}`); console.error(`[GitManager][Coherence:Failed] Failed to resolve environment stage: ${e.message}`);
} }
}, },
// #endregion loadCurrentEnvironmentStage
// #region checkStatus [C:3] [TYPE Function]
// @BRIEF Check if Git repository is initialized for the dashboard.
// @PRE dashboardId is a non-numeric slug; envId is required (resolved with localStorage fallback).
// @POST initialized=true if repository found and workspace loaded; initialized=false if not found or envId missing.
// @SIDE_EFFECT Fetches /git/repositories/{ref}/branches; may fetch /git/repositories/{ref}/status and /diff on success.
async checkStatus() { async checkStatus() {
if (isNumericDashboardRef(s().dashboardId)) { if (isNumericDashboardRef(s().dashboardId)) {
u({ checkingStatus: false, initialized: false }); u({ checkingStatus: false, initialized: false });
@@ -35,18 +48,25 @@ export function createGitHandlers(getState, setState) {
return; return;
} }
u({ checkingStatus: true }); u({ checkingStatus: true });
if (!s().envId) {
u({ initialized: false, checkingStatus: false });
return;
}
try { try {
await gitService.getBranches(s().dashboardId, s().envId); await gitService.getBranches(s().dashboardId, s().envId);
u({ initialized: true }); u({ initialized: true });
await this.loadWorkspace(); await handlers.loadWorkspace();
} catch (_e) { } catch (_e) {
u({ initialized: false }); u({ initialized: false });
try { const configs = await gitService.getConfigs(); u({ configs }); } catch (_e2) { u({ configs: [] }); }
const defaultConfig = resolveDefaultConfig(s().configs, s().selectedConfigId);
if (defaultConfig?.id) u({ selectedConfigId: defaultConfig.id });
} finally { u({ checkingStatus: false }); } } finally { u({ checkingStatus: false }); }
}, },
// #endregion checkStatus
// #region loadWorkspace [C:2] [TYPE Function]
// @BRIEF Load Git workspace status and diff for initialized repository.
// @PRE initialized is true; envId is required when ref is slug.
// @POST workspaceStatus and workspaceDiff are updated; currentBranch synced from server.
// @SIDE_EFFECT Fetches /status and /diff for the dashboard repository.
async loadWorkspace() { async loadWorkspace() {
if (!s().initialized) return; if (!s().initialized) return;
u({ workspaceLoading: true }); u({ workspaceLoading: true });
@@ -59,18 +79,29 @@ export function createGitHandlers(getState, setState) {
s().toast(e.message || 'Не удалось загрузить изменения', 'error'); s().toast(e.message || 'Не удалось загрузить изменения', 'error');
} finally { u({ workspaceLoading: false }); } } finally { u({ workspaceLoading: false }); }
}, },
// #endregion loadWorkspace
// #region handleSync [C:2] [TYPE Function]
// @BRIEF Synchronize local dashboard state with Git repository.
// @PRE dashboardId is a non-numeric slug; envId required when ref is slug.
// @POST Dashboard state synced to Git workspace; workspace reloaded.
// @SIDE_EFFECT POSTs to /git/repositories/{ref}/sync; reloads workspace on success.
async handleSync() { async handleSync() {
if (isNumericDashboardRef(s().dashboardId)) { s().toast('GitManager requires dashboard slug. Numeric ID is forbidden.', 'error'); return; } if (isNumericDashboardRef(s().dashboardId)) { s().toast('GitManager requires dashboard slug. Numeric ID is forbidden.', 'error'); return; }
u({ loading: true }); u({ loading: true });
try { try {
const sourceEnvId = localStorage.getItem('selected_env_id'); const sourceEnvId = s().envId || localStorage.getItem('selected_env_id');
await gitService.sync(s().dashboardId, sourceEnvId, s().envId); await gitService.sync(s().dashboardId, sourceEnvId, s().envId);
s().toast(s().t?.git?.sync_success || 'Состояние дашборда синхронизировано с Git', 'success'); s().toast(s().t?.git?.sync_success || 'Состояние дашборда синхронизировано с Git', 'success');
await this.loadWorkspace(); await handlers.loadWorkspace();
} catch (e) { s().toast(e.message, 'error'); } finally { u({ loading: false }); } } catch (e) { s().toast(e.message, 'error'); } finally { u({ loading: false }); }
}, },
// #endregion handleSync
// #region handleGenerateMessage [C:2] [TYPE Function]
// @BRIEF Generate AI commit message from dashboard diff.
// @SIDE_EFFECT POSTs to /git/repositories/{ref}/generate-message.
// @POST commitMessage state updated with generated text.
async handleGenerateMessage() { async handleGenerateMessage() {
u({ generatingMessage: true }); u({ generatingMessage: true });
try { try {
@@ -79,7 +110,13 @@ export function createGitHandlers(getState, setState) {
s().toast(s().t?.git?.commit_message_generated || 'Сообщение для коммита сгенерировано', 'success'); s().toast(s().t?.git?.commit_message_generated || 'Сообщение для коммита сгенерировано', 'success');
} catch (e) { s().toast(e.message || s().t?.git?.commit_message_failed || 'Не удалось сгенерировать сообщение', 'error'); } finally { u({ generatingMessage: false }); } } catch (e) { s().toast(e.message || s().t?.git?.commit_message_failed || 'Не удалось сгенерировать сообщение', 'error'); } finally { u({ generatingMessage: false }); }
}, },
// #endregion handleGenerateMessage
// #region handleCommit [C:2] [TYPE Function]
// @BRIEF Stage and commit workspace changes, optionally push.
// @PRE commitMessage is non-empty; hasWorkspaceChanges is true; envId required when ref is slug.
// @POST Changes committed (and pushed if autoPushAfterCommit); workspace reloaded.
// @SIDE_EFFECT POSTs to /commit and optionally /push.
async handleCommit() { async handleCommit() {
if (!s().commitMessage || !s().hasWorkspaceChanges) return; if (!s().commitMessage || !s().hasWorkspaceChanges) return;
u({ committing: true }); u({ committing: true });
@@ -90,22 +127,33 @@ export function createGitHandlers(getState, setState) {
s().toast(s().t?.git?.commit_and_push_success || 'Коммит создан и отправлен в remote', 'success'); s().toast(s().t?.git?.commit_and_push_success || 'Коммит создан и отправлен в remote', 'success');
} else { s().toast(s().t?.git?.commit_success || 'Коммит успешно создан', 'success'); } } else { s().toast(s().t?.git?.commit_success || 'Коммит успешно создан', 'success'); }
u({ commitMessage: '' }); u({ commitMessage: '' });
await this.loadWorkspace(); await handlers.loadWorkspace();
} catch (e) { s().toast(e.message, 'error'); } finally { u({ committing: false }); } } catch (e) { s().toast(e.message, 'error'); } finally { u({ committing: false }); }
}, },
// #endregion handleCommit
// #region openUnfinishedMergeDialogFromError [C:2] [TYPE Function]
// @BRIEF Parse error for unfinished merge context and open dialog.
// @PRE error contains GIT_UNFINISHED_MERGE error_code or related detail.
// @POST Returns true and opens dialog if merge context found; false otherwise.
openUnfinishedMergeDialogFromError(error) { openUnfinishedMergeDialogFromError(error) {
const context = extractUnfinishedMergeContext(error); const context = extractUnfinishedMergeContext(error);
if (!context) return false; if (!context) return false;
u({ unfinishedMergeContext: context, showUnfinishedMergeDialog: true }); u({ unfinishedMergeContext: context, showUnfinishedMergeDialog: true });
return true; return true;
}, },
// #endregion openUnfinishedMergeDialogFromError
// #region loadMergeRecoveryState [C:2] [TYPE Function]
// @BRIEF Load merge recovery state from backend for unfinished merge.
// @PRE Repository exists; envId required when ref is slug.
// @POST unfinishedMergeContext is populated; dialog closed if no merge in progress.
// @SIDE_EFFECT GETs /merge/status from backend.
async loadMergeRecoveryState() { async loadMergeRecoveryState() {
u({ mergeRecoveryLoading: true }); u({ mergeRecoveryLoading: true });
try { try {
const status = await gitService.getMergeStatus(s().dashboardId, s().envId); const status = await gitService.getMergeStatus(s().dashboardId, s().envId);
if (!status?.has_unfinished_merge) { this.closeUnfinishedMergeDialog(); return; } if (!status?.has_unfinished_merge) { handlers.closeUnfinishedMergeDialog(); return; }
u({ u({
unfinishedMergeContext: { unfinishedMergeContext: {
...(s().unfinishedMergeContext || {}), ...(s().unfinishedMergeContext || {}),
@@ -122,11 +170,21 @@ export function createGitHandlers(getState, setState) {
}); });
} catch (e) { s().toast(e.message || 'Failed to load merge status', 'error'); } finally { u({ mergeRecoveryLoading: false }); } } catch (e) { s().toast(e.message || 'Failed to load merge status', 'error'); } finally { u({ mergeRecoveryLoading: false }); }
}, },
// #endregion loadMergeRecoveryState
// #region closeUnfinishedMergeDialog [C:1] [TYPE Function]
// @BRIEF Close unfinished merge dialog and reset merge state.
// @POST Dialog hidden; mergeConflicts, showConflictResolver reset.
closeUnfinishedMergeDialog() { closeUnfinishedMergeDialog() {
u({ showUnfinishedMergeDialog: false, unfinishedMergeContext: null, mergeConflicts: [], showConflictResolver: false }); u({ showUnfinishedMergeDialog: false, unfinishedMergeContext: null, mergeConflicts: [], showConflictResolver: false });
}, },
// #endregion closeUnfinishedMergeDialog
// #region handleOpenConflictResolver [C:2] [TYPE Function]
// @BRIEF Load merge conflicts and open conflict resolver UI.
// @PRE Unfinished merge is in progress; envId required when ref is slug.
// @POST mergeConflicts populated; showConflictResolver set to true if conflicts found.
// @SIDE_EFFECT GETs /merge/conflicts from backend.
async handleOpenConflictResolver() { async handleOpenConflictResolver() {
u({ mergeRecoveryLoading: true }); u({ mergeRecoveryLoading: true });
try { try {
@@ -135,7 +193,12 @@ export function createGitHandlers(getState, setState) {
u({ mergeConflicts: mc, showConflictResolver: true }); u({ mergeConflicts: mc, showConflictResolver: true });
} catch (e) { s().toast(e.message || 'Failed to load merge conflicts', 'error'); } finally { u({ mergeRecoveryLoading: false }); } } catch (e) { s().toast(e.message || 'Failed to load merge conflicts', 'error'); } finally { u({ mergeRecoveryLoading: false }); }
}, },
// #endregion handleOpenConflictResolver
// #region handleResolveConflicts [C:2] [TYPE Function]
// @BRIEF Apply conflict resolutions and stage resolved files.
// @PRE event.detail maps file paths to resolution strategies; envId required when ref is slug.
// @POST Conflicts resolved and staged; resolver closed; workspace reloaded.
async handleResolveConflicts(event) { async handleResolveConflicts(event) {
const detail = event?.detail || {}; const detail = event?.detail || {};
const resolutions = Object.entries(detail).map(([fp, r]) => ({ file_path: fp, resolution: r })); const resolutions = Object.entries(detail).map(([fp, r]) => ({ file_path: fp, resolution: r }));
@@ -145,41 +208,60 @@ export function createGitHandlers(getState, setState) {
await gitService.resolveMergeConflicts(s().dashboardId, resolutions, s().envId); await gitService.resolveMergeConflicts(s().dashboardId, resolutions, s().envId);
s().toast(s().t?.git?.unfinished_merge?.resolve_success || 'Conflicts were resolved and staged', 'success'); s().toast(s().t?.git?.unfinished_merge?.resolve_success || 'Conflicts were resolved and staged', 'success');
u({ showConflictResolver: false }); u({ showConflictResolver: false });
await this.loadMergeRecoveryState(); await handlers.loadMergeRecoveryState();
await this.loadWorkspace(); await handlers.loadWorkspace();
} catch (e) { s().toast(e.message || 'Failed to resolve conflicts', 'error'); } finally { u({ mergeResolveInProgress: false }); } } catch (e) { s().toast(e.message || 'Failed to resolve conflicts', 'error'); } finally { u({ mergeResolveInProgress: false }); }
}, },
// #endregion handleResolveConflicts
// #region handleAbortUnfinishedMerge [C:2] [TYPE Function]
// @BRIEF Abort current unfinished merge.
// @PRE envId required when ref is slug.
// @POST Merge aborted; dialog closed; workspace reloaded.
async handleAbortUnfinishedMerge() { async handleAbortUnfinishedMerge() {
u({ mergeAbortInProgress: true }); u({ mergeAbortInProgress: true });
try { try {
await gitService.abortMerge(s().dashboardId, s().envId); await gitService.abortMerge(s().dashboardId, s().envId);
s().toast(s().t?.git?.unfinished_merge?.abort_success || 'Merge was aborted', 'success'); s().toast(s().t?.git?.unfinished_merge?.abort_success || 'Merge was aborted', 'success');
this.closeUnfinishedMergeDialog(); handlers.closeUnfinishedMergeDialog();
await this.loadWorkspace(); await handlers.loadWorkspace();
} catch (e) { s().toast(e.message || 'Failed to abort merge', 'error'); } finally { u({ mergeAbortInProgress: false }); } } catch (e) { s().toast(e.message || 'Failed to abort merge', 'error'); } finally { u({ mergeAbortInProgress: false }); }
}, },
// #endregion handleAbortUnfinishedMerge
// #region handleContinueUnfinishedMerge [C:2] [TYPE Function]
// @BRIEF Finalize unfinished merge by creating merge commit.
// @PRE All conflicts resolved; envId required when ref is slug.
// @POST Merge commit created; dialog closed; workspace reloaded.
async handleContinueUnfinishedMerge() { async handleContinueUnfinishedMerge() {
u({ mergeContinueInProgress: true }); u({ mergeContinueInProgress: true });
try { try {
await gitService.continueMerge(s().dashboardId, '', s().envId); await gitService.continueMerge(s().dashboardId, '', s().envId);
s().toast(s().t?.git?.unfinished_merge?.continue_success || 'Merge commit created successfully', 'success'); s().toast(s().t?.git?.unfinished_merge?.continue_success || 'Merge commit created successfully', 'success');
this.closeUnfinishedMergeDialog(); handlers.closeUnfinishedMergeDialog();
await this.loadWorkspace(); await handlers.loadWorkspace();
} catch (e) { } catch (e) {
s().toast(e.message || 'Failed to continue merge', 'error'); s().toast(e.message || 'Failed to continue merge', 'error');
await this.loadMergeRecoveryState(); await handlers.loadMergeRecoveryState();
} finally { u({ mergeContinueInProgress: false }); } } finally { u({ mergeContinueInProgress: false }); }
}, },
// #endregion handleContinueUnfinishedMerge
// #region getUnfinishedMergeCommandsText [C:1] [TYPE Function]
// @BRIEF Get shell commands for manual merge recovery.
// @POST Returns concatenated command string or empty string.
getUnfinishedMergeCommandsText() { getUnfinishedMergeCommandsText() {
if (!s().unfinishedMergeContext?.commands?.length) return ''; if (!s().unfinishedMergeContext?.commands?.length) return '';
return s().unfinishedMergeContext.commands.join('\n'); return s().unfinishedMergeContext.commands.join('\n');
}, },
// #endregion getUnfinishedMergeCommandsText
// #region handleCopyUnfinishedMergeCommands [C:1] [TYPE Function]
// @BRIEF Copy merge recovery commands to clipboard.
// @PRE commands exist in unfinishedMergeContext.
// @POST Commands copied to clipboard; toast shown.
async handleCopyUnfinishedMergeCommands() { async handleCopyUnfinishedMergeCommands() {
const text = this.getUnfinishedMergeCommandsText(); const text = handlers.getUnfinishedMergeCommandsText();
if (!text) { s().toast(s().t?.git?.unfinished_merge?.copy_empty || 'Команды для копирования отсутствуют', 'warning'); return; } if (!text) { s().toast(s().t?.git?.unfinished_merge?.copy_empty || 'Команды для копирования отсутствуют', 'warning'); return; }
u({ copyingUnfinishedMergeCommands: true }); u({ copyingUnfinishedMergeCommands: true });
try { try {
@@ -187,29 +269,46 @@ export function createGitHandlers(getState, setState) {
s().toast(s().t?.git?.unfinished_merge?.copy_success || 'Команды скопированы в буфер обмена', 'success'); s().toast(s().t?.git?.unfinished_merge?.copy_success || 'Команды скопированы в буфер обмена', 'success');
} catch (_e) { s().toast(s().t?.git?.unfinished_merge?.copy_failed || 'Не удалось скопировать команды', 'error'); } finally { u({ copyingUnfinishedMergeCommands: false }); } } catch (_e) { s().toast(s().t?.git?.unfinished_merge?.copy_failed || 'Не удалось скопировать команды', 'error'); } finally { u({ copyingUnfinishedMergeCommands: false }); }
}, },
// #endregion handleCopyUnfinishedMergeCommands
// #region handlePull [C:2] [TYPE Function]
// @BRIEF Pull changes from remote repository.
// @PRE Remote 'origin' configured; envId required when ref is slug.
// @POST Local repository updated with remote changes; workspace reloaded.
// @SIDE_EFFECT POSTs to /pull; may open merge dialog on GIT_UNFINISHED_MERGE.
async handlePull() { async handlePull() {
u({ isPulling: true }); u({ isPulling: true });
try { try {
await gitService.pull(s().dashboardId, s().envId); await gitService.pull(s().dashboardId, s().envId);
s().toast(s().t?.git?.pull_success || 'Изменения получены из Git', 'success'); s().toast(s().t?.git?.pull_success || 'Изменения получены из Git', 'success');
await this.loadWorkspace(); await handlers.loadWorkspace();
} catch (e) { } catch (e) {
const handled = this.openUnfinishedMergeDialogFromError(e); const handled = handlers.openUnfinishedMergeDialogFromError(e);
if (handled) await this.loadMergeRecoveryState(); if (handled) await handlers.loadMergeRecoveryState();
else s().toast(e.message, 'error'); else s().toast(e.message, 'error');
} finally { u({ isPulling: false }); } } finally { u({ isPulling: false }); }
}, },
// #endregion handlePull
// #region handlePush [C:2] [TYPE Function]
// @BRIEF Push local commits to remote repository.
// @PRE Remote 'origin' configured; envId required when ref is slug.
// @POST Remote updated with local commits; workspace reloaded.
async handlePush() { async handlePush() {
u({ isPushing: true }); u({ isPushing: true });
try { try {
await gitService.push(s().dashboardId, s().envId); await gitService.push(s().dashboardId, s().envId);
s().toast(s().t?.git?.push_success || 'Изменения отправлены в Git', 'success'); s().toast(s().t?.git?.push_success || 'Изменения отправлены в Git', 'success');
await this.loadWorkspace(); await handlers.loadWorkspace();
} catch (e) { s().toast(e.message, 'error'); } finally { u({ isPushing: false }); } } catch (e) { s().toast(e.message, 'error'); } finally { u({ isPushing: false }); }
}, },
// #endregion handlePush
// #region handlePromote [C:3] [TYPE Function]
// @BRIEF Promote changes between branches via MR or direct merge.
// @PRE promoteFromBranch and promoteToBranch differ; direct mode requires reason; envId required when ref is slug.
// @POST MR created on Git server (MR mode) or direct merge executed (direct mode).
// @SIDE_EFFECT Opens MR URL in new tab (MR mode); writes policy violation to logs (direct mode).
async handlePromote() { async handlePromote() {
const st = s(); const st = s();
if (!st.promoteFromBranch || !st.promoteToBranch || st.promoteFromBranch === st.promoteToBranch) { st.toast('Выберите разные исходную и целевую ветки', 'error'); return; } if (!st.promoteFromBranch || !st.promoteToBranch || st.promoteFromBranch === st.promoteToBranch) { st.toast('Выберите разные исходную и целевую ветки', 'error'); return; }
@@ -226,7 +325,12 @@ export function createGitHandlers(getState, setState) {
else { if (response?.url) window.open(response.url, '_blank', 'noopener,noreferrer'); st.toast('Merge Request создан на Git сервере', 'success'); } else { if (response?.url) window.open(response.url, '_blank', 'noopener,noreferrer'); st.toast('Merge Request создан на Git сервере', 'success'); }
} catch (e) { st.toast(e.message, 'error'); } finally { u({ promoting: false }); } } catch (e) { st.toast(e.message, 'error'); } finally { u({ promoting: false }); }
}, },
// #endregion handlePromote
// #region openDeployModal [C:2] [TYPE Function]
// @BRIEF Open deployment target modal with PROD confirmation.
// @PRE dashboardId is a valid slug.
// @POST showDeployModal set to true if PROD confirmation passes.
openDeployModal() { openDeployModal() {
const st = s(); const st = s();
if (st.currentEnvStage === 'PROD') { if (st.currentEnvStage === 'PROD') {
@@ -236,7 +340,13 @@ export function createGitHandlers(getState, setState) {
} }
u({ showDeployModal: true }); u({ showDeployModal: true });
}, },
// #endregion openDeployModal
// #region handleCreateRemoteRepo [C:2] [TYPE Function]
// @BRIEF Create a remote repository on the selected Git provider.
// @PRE A Git config is selected; user provides a repo name via prompt.
// @POST remoteUrl set to created (or existing) repository clone URL.
// @SIDE_EFFECT POSTs to /git/config/{id}/repositories; prompts user for repo name.
async handleCreateRemoteRepo() { async handleCreateRemoteRepo() {
const config = resolveDefaultConfig(s().configs, s().selectedConfigId); const config = resolveDefaultConfig(s().configs, s().selectedConfigId);
if (!config) { s().toast(s().t?.git?.init_validation_error || 'Сначала выберите Git сервер', 'error'); return; } if (!config) { s().toast(s().t?.git?.init_validation_error || 'Сначала выберите Git сервер', 'error'); return; }
@@ -256,20 +366,37 @@ export function createGitHandlers(getState, setState) {
if (!url) throw new Error('Remote repository created, but URL is empty'); if (!url) throw new Error('Remote repository created, but URL is empty');
u({ remoteUrl: url }); u({ remoteUrl: url });
s().toast(`Repository created on ${config.provider}`, 'success'); s().toast(`Repository created on ${config.provider}`, 'success');
} catch (e) { s().toast(e.message, 'error'); } finally { u({ creatingRemoteRepo: false }); } } catch (e) {
if (e?.status === 409 && /already exists/i.test(String(e?.message || ''))) {
s().toast(s().t?.git?.repo_already_exists || 'Repository already exists. Enter its URL below and click Init.', 'warning');
} else { s().toast(e.message, 'error'); }
} finally { u({ creatingRemoteRepo: false }); }
}, },
// #endregion handleCreateRemoteRepo
// #region handleInit [C:3] [TYPE Function]
// @BRIEF Initialize local Git repository for the dashboard: clone remote.
// @PRE selectedConfigId is set; remoteUrl is set; envId required when ref is slug.
// @POST Repository initialized; initialized=true; workspace loaded.
// @SIDE_EFFECT POSTs to /git/repositories/{ref}/init; loads workspace on success.
async handleInit() { async handleInit() {
if (!s().selectedConfigId || !s().remoteUrl) { s().toast(s().t?.git?.init_validation_error || 'Заполните все поля', 'error'); return; } if (!s().selectedConfigId || !s().remoteUrl) { s().toast(s().t?.git?.init_validation_error || 'Заполните все поля', 'error'); return; }
// ── Guard: slug-based init requires envId ──
if (!s().envId && !isNumericDashboardRef(s().dashboardId)) {
s().toast('Environment must be selected to initialize Git for this dashboard.', 'error');
return;
}
u({ loading: true }); u({ loading: true });
try { try {
await gitService.initRepository(s().dashboardId, s().selectedConfigId, s().remoteUrl, s().envId); await gitService.initRepository(s().dashboardId, s().selectedConfigId, s().remoteUrl, s().envId);
s().toast(s().t?.git?.init_success || 'Репозиторий инициализирован', 'success'); s().toast(s().t?.git?.init_success || 'Репозиторий инициализирован', 'success');
const provider = resolveDefaultConfig(s().configs, s().selectedConfigId)?.provider || ''; const provider = resolveDefaultConfig(s().configs, s().selectedConfigId)?.provider || '';
u({ initialized: true, repositoryProvider: provider }); u({ initialized: true, repositoryProvider: provider });
await this.loadWorkspace(); await handlers.loadWorkspace();
} catch (e) { s().toast(e.message, 'error'); } finally { u({ loading: false }); } } catch (e) { s().toast(e.message, 'error'); } finally { u({ loading: false }); }
}, },
// #endregion handleInit
}; };
return handlers;
} }
// #endregion UseGitManager // #endregion UseGitManager

View File

@@ -34,7 +34,11 @@ function shouldSuppressApiErrorToast(endpoint, error) {
const isUnfinishedMergeError = error?.status === 409 && (String(error?.error_code || '') === 'GIT_UNFINISHED_MERGE' || String(error?.detail?.error_code || '') === 'GIT_UNFINISHED_MERGE'); const isUnfinishedMergeError = error?.status === 409 && (String(error?.error_code || '') === 'GIT_UNFINISHED_MERGE' || String(error?.detail?.error_code || '') === 'GIT_UNFINISHED_MERGE');
const isDatasetClarificationEndpoint = typeof endpoint === 'string' && /\/dataset-orchestration\/sessions\/[^/]+\/clarification$/.test(endpoint); const isDatasetClarificationEndpoint = typeof endpoint === 'string' && /\/dataset-orchestration\/sessions\/[^/]+\/clarification$/.test(endpoint);
const isMissingClarificationSession = error?.status === 404 && /Clarification session not found/i.test(String(error?.message || '')); const isMissingClarificationSession = error?.status === 404 && /Clarification session not found/i.test(String(error?.message || ''));
return (isGitStatusEndpoint && isNoRepoError) || (isGitPullEndpoint && isUnfinishedMergeError) || (isDatasetClarificationEndpoint && isMissingClarificationSession); const isGitRepoEndpoint = typeof endpoint === 'string' && endpoint.startsWith('/git/repositories/');
const isMissingEnvIdError = error?.status === 400 && /env_id is required/i.test(String(error?.message || ''));
const isGitConfigReposEndpoint = typeof endpoint === 'string' && /^\/git\/config\/[^/]+\/repositories$/.test(endpoint);
const isRepoAlreadyExistsError = error?.status === 409 && /already exists/i.test(String(error?.message || ''));
return (isGitStatusEndpoint && isNoRepoError) || (isGitPullEndpoint && isUnfinishedMergeError) || (isDatasetClarificationEndpoint && isMissingClarificationSession) || (isGitRepoEndpoint && isMissingEnvIdError) || (isGitConfigReposEndpoint && isRepoAlreadyExistsError);
} }
export const getWsUrl = (taskId) => { export const getWsUrl = (taskId) => {

View File

@@ -0,0 +1,214 @@
<!-- #region DatabaseSearchCombobox [C:3] [TYPE Component] [SEMANTICS ui, database, search, combobox, single-select] -->
<!-- @BRIEF Searchable single-select combobox for Superset databases. Shows name and engine. -->
<!-- @UX_STATE default -> Input with dropdown hidden, selected db name shown if selected -->
<!-- @UX_STATE dropdown_open -> Dropdown visible with filtered database options -->
<!-- @UX_STATE empty -> No databases match the search filter -->
<!-- @UX_STATE loading -> Spinner in input while fetching databases -->
<!-- @UX_STATE error -> Toast shown on API failure -->
<!-- @UX_FEEDBACK Toast on API fetch error -->
<!-- @UX_REACTIVITY envId -> resets databases on change, triggers refetch; Props -> $props(); LocalState -> $state(...) -->
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:api] -->
<!-- @RELATION CALLED_BY -> [MapperTool] -->
<script>
import { api } from '$lib/api';
import { addToast } from '$lib/toasts.js';
/**
* Searchable single-select combobox for Superset databases.
* @prop {string} envId - Environment ID for API calls
* @prop {string} value - Bindable selected database ID
* @prop {string} label - Label text above input
* @prop {string} placeholder - Placeholder for search input
* @prop {boolean} disabled - Disable the input
*/
let {
envId = '',
value = $bindable(''),
label = '',
placeholder = 'Search databases...',
disabled = false,
} = $props();
let searchQuery = $state('');
let isOpen = $state(false);
let databases = $state([]);
let isLoading = $state(false);
let containerEl = $state(null);
let selectedDb = $state(null);
// Find the currently selected database
$effect(() => {
if (value && databases.length > 0) {
const found = databases.find(d => String(d.id) === String(value));
if (found) {
selectedDb = found;
searchQuery = found.database_name;
}
} else if (!value) {
selectedDb = null;
if (!isOpen) searchQuery = '';
}
});
function handleInput(e) {
searchQuery = e.target.value;
if (!isOpen) isOpen = true;
// Filtering is handled by $derived(filteredDatabases)
}
async function fetchDatabases() {
if (!envId) return;
isLoading = true;
try {
const result = await api.getEnvironmentDatabases(envId);
databases = result || [];
if (value) {
selectedDb = databases.find(d => String(d.id) === String(value)) || selectedDb;
if (selectedDb) searchQuery = selectedDb.database_name;
}
} catch (e) {
databases = [];
addToast(e.message || 'Failed to load databases', 'error');
} finally {
isLoading = false;
}
}
function handleFocus() {
if (!disabled) {
isOpen = true;
if (databases.length === 0 && envId) {
fetchDatabases();
}
}
}
function selectDatabase(db) {
value = String(db.id);
selectedDb = db;
searchQuery = db.database_name;
isOpen = false;
}
function clearSelection() {
value = '';
selectedDb = null;
searchQuery = '';
isOpen = true;
if (envId) fetchDatabases();
}
function handleClickOutside(e) {
if (containerEl && !containerEl.contains(e.target)) {
isOpen = false;
if (selectedDb && !isOpen) {
searchQuery = selectedDb.database_name;
} else if (!selectedDb && !isOpen) {
searchQuery = '';
}
}
}
$effect(() => {
if (typeof document !== 'undefined') {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
// Reset when envId changes — clear cache so handleFocus triggers refetch
$effect(() => {
if (envId) {
databases = [];
selectedDb = null;
if (!value) searchQuery = '';
if (isOpen) fetchDatabases();
}
});
let filteredDatabases = $derived(
searchQuery
? databases.filter(
(db) =>
db.database_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
(db.engine && db.engine.toLowerCase().includes(searchQuery.toLowerCase())),
)
: databases,
);
</script>
<div bind:this={containerEl} class="relative">
{#if label}
<label class="block text-sm font-medium text-gray-700 mb-1">{label}</label>
{/if}
<!-- Search Input -->
<div class="relative">
<input
type="text"
value={searchQuery}
oninput={handleInput}
onfocus={handleFocus}
placeholder={placeholder}
disabled={disabled}
class="w-full px-3 py-2 pr-8 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring {disabled ? 'bg-gray-50 cursor-not-allowed' : ''}"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
/>
{#if isLoading}
<div class="absolute right-2.5 top-2.5 animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
{:else if value && !isOpen}
<button
onclick={clearSelection}
class="absolute right-2.5 top-2.5 text-gray-400 hover:text-gray-600"
aria-label="Clear selection"
type="button"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{:else if !isOpen}
<div class="absolute right-2.5 top-2.5 text-gray-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
{/if}
</div>
<!-- Dropdown -->
{#if isOpen}
<div class="absolute z-50 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg" role="listbox">
<div class="max-h-60 overflow-y-auto">
{#if isLoading && databases.length === 0}
<div class="px-3 py-6 text-center text-sm text-gray-400">
<div class="inline-block animate-spin h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full mb-2"></div>
<p>Loading...</p>
</div>
{:else if filteredDatabases.length === 0}
<div class="px-3 py-6 text-center text-sm text-gray-400 italic">
{searchQuery ? 'No databases match your search' : 'No databases available'}
</div>
{:else}
{#each filteredDatabases as db (db.id)}
<button
type="button"
onclick={() => selectDatabase(db)}
class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-blue-50 border-b border-gray-100 last:border-b-0 transition-colors {String(db.id) === String(value) ? 'bg-blue-50' : ''}"
role="option"
aria-selected={String(db.id) === String(value)}
>
<span class="font-medium text-gray-900">{db.database_name}</span>
{#if db.engine}
<span class="ml-auto text-xs text-gray-400">{db.engine}</span>
{/if}
</button>
{/each}
{/if}
</div>
</div>
{/if}
</div>
<!-- #endregion DatabaseSearchCombobox -->

View File

@@ -0,0 +1,204 @@
<!-- #region DatasetSearchCombobox [C:3] [TYPE Component] [SEMANTICS ui, dataset, search, combobox, single-select] -->
<!-- @BRIEF Searchable single-select combobox for datasets. Shows name, schema, and database. -->
<!-- @UX_STATE default -> Input with dropdown hidden, selected dataset name shown if selected -->
<!-- @UX_STATE dropdown_open -> Dropdown visible with filtered dataset options -->
<!-- @UX_STATE empty -> No datasets match the search filter -->
<!-- @UX_STATE loading -> Spinner in input while fetching datasets from API -->
<!-- @UX_STATE error -> Toast shown on API failure, dropdown shows error message -->
<!-- @UX_FEEDBACK Toast on API fetch error -->
<!-- @UX_REACTIVITY envId -> resets datasets on change, triggers refetch; Props -> $props(); LocalState -> $state(...) -->
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:api] -->
<!-- @RELATION CALLED_BY -> [MapperTool] -->
<script>
import { api } from '$lib/api';
import { addToast } from '$lib/toasts.js';
/**
* Searchable single-select combobox for datasets.
* @prop {string} envId - Environment ID for API calls
* @prop {string} value - Bindable selected dataset ID
* @prop {string} label - Label text above input
* @prop {string} placeholder - Placeholder for search input
* @prop {boolean} disabled - Disable the input
*/
let {
envId = '',
value = $bindable(''),
label = '',
placeholder = 'Search datasets...',
disabled = false,
} = $props();
let searchQuery = $state('');
let isOpen = $state(false);
let datasets = $state([]);
let isLoading = $state(false);
let containerEl = $state(null);
let selectedDataset = $state(null);
// Find the currently selected dataset from the loaded list
$effect(() => {
if (value && datasets.length > 0) {
const found = datasets.find(d => String(d.id) === String(value));
if (found) {
selectedDataset = found;
searchQuery = found.table_name;
}
} else if (!value) {
selectedDataset = null;
if (!isOpen) searchQuery = '';
}
});
async function handleInput(e) {
searchQuery = e.target.value;
if (!isOpen) isOpen = true;
await fetchDatasets(searchQuery);
}
async function fetchDatasets(search) {
if (!envId) return;
isLoading = true;
try {
const result = await api.getDatasets(envId, { search: search || undefined, page_size: 50 });
datasets = result?.datasets || result || [];
// Re-apply selection highlight
if (value) {
selectedDataset = datasets.find(d => String(d.id) === String(value)) || selectedDataset;
}
} catch (e) {
datasets = [];
addToast(e.message || 'Failed to load datasets', 'error');
} finally {
isLoading = false;
}
}
function handleFocus() {
if (!disabled) {
isOpen = true;
if (datasets.length === 0 && envId) {
fetchDatasets(searchQuery);
}
}
}
function selectDataset(dataset) {
value = String(dataset.id);
selectedDataset = dataset;
searchQuery = dataset.table_name;
isOpen = false;
}
function clearSelection() {
value = '';
selectedDataset = null;
searchQuery = '';
isOpen = true;
if (envId) fetchDatasets('');
}
function handleClickOutside(e) {
if (containerEl && !containerEl.contains(e.target)) {
isOpen = false;
// Restore search query to selected dataset name if any
if (selectedDataset && !isOpen) {
searchQuery = selectedDataset.table_name;
} else if (!selectedDataset && !isOpen) {
searchQuery = '';
}
}
}
$effect(() => {
if (typeof document !== 'undefined') {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
// Reset when envId changes — clear cache so handleFocus triggers refetch
$effect(() => {
if (envId) {
datasets = [];
selectedDataset = null;
if (!value) searchQuery = '';
if (isOpen) fetchDatasets(searchQuery);
}
});
</script>
<div bind:this={containerEl} class="relative">
{#if label}
<label class="block text-sm font-medium text-gray-700 mb-1">{label}</label>
{/if}
<!-- Search Input -->
<div class="relative">
<input
type="text"
value={searchQuery}
oninput={handleInput}
onfocus={handleFocus}
placeholder={placeholder}
disabled={disabled}
class="w-full px-3 py-2 pr-8 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring {disabled ? 'bg-gray-50 cursor-not-allowed' : ''}"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
/>
{#if isLoading}
<div class="absolute right-2.5 top-2.5 animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
{:else if value && !isOpen}
<button
onclick={clearSelection}
class="absolute right-2.5 top-2.5 text-gray-400 hover:text-gray-600"
aria-label="Clear selection"
type="button"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{:else if !isOpen}
<div class="absolute right-2.5 top-2.5 text-gray-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
{/if}
</div>
<!-- Dropdown -->
{#if isOpen}
<div class="absolute z-50 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg" role="listbox">
<div class="max-h-60 overflow-y-auto">
{#if isLoading && datasets.length === 0}
<div class="px-3 py-6 text-center text-sm text-gray-400">
<div class="inline-block animate-spin h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full mb-2"></div>
<p>Loading...</p>
</div>
{:else if datasets.length === 0}
<div class="px-3 py-6 text-center text-sm text-gray-400 italic">
{searchQuery ? 'No datasets match your search' : 'No datasets available'}
</div>
{:else}
{#each datasets as ds (ds.id)}
<button
type="button"
onclick={() => selectDataset(ds)}
class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-blue-50 border-b border-gray-100 last:border-b-0 transition-colors {String(ds.id) === String(value) ? 'bg-blue-50' : ''}"
role="option"
aria-selected={String(ds.id) === String(value)}
>
<span class="font-medium text-gray-900">{ds.table_name}</span>
<span class="text-gray-400 shrink-0">{ds.schema || ds.schema_name || 'public'}</span>
<span class="ml-auto text-xs text-gray-400 shrink-0">{ds.database} · {ds.mapped_fields ? `${ds.mapped_fields.mapped}/${ds.mapped_fields.total}` : 'unknown'}</span>
</button>
{/each}
{/if}
</div>
</div>
{/if}
</div>
<!-- #endregion DatasetSearchCombobox -->

View File

@@ -1,20 +1,17 @@
// #region GitServiceClient [C:3] [TYPE Service] [SEMANTICS git, service, api, repository, branch] // #region GitServiceClient [C:3] [TYPE Service] [SEMANTICS git, service, api, repository, branch]
// @BRIEF API client for Git operations including repository management, branch operations, commits, merges, and deployments. // @BRIEF API client for Git operations repository management, branches, commits, merges, deployments.
// @LAYER Service // @LAYER Service
// @RELATION DEPENDS_ON -> [ApiModule] // @RELATION DEPENDS_ON -> [ApiModule]
// #region GitServiceClient:Module [TYPE Function]
/**
* @SEMANTICS: git, service, api, client
* @PURPOSE: API client for Git operations, managing the communication between frontend and backend.
* @LAYER Service
* @RELATION DEPENDS_ON -> specs/011-git-integration-dashboard/contracts/api.md
*/
import { requestApi } from '../lib/api'; import { requestApi } from '../lib/api';
const API_BASE = '/git'; const API_BASE = '/git';
// #region buildDashboardRepoEndpoint [C:2] [TYPE Function]
// @BRIEF Build /git/repositories/{ref}{suffix} endpoint with optional env_id query param.
// @PRE dashboardRef is non-empty string.
// @POST Returns absolute API endpoint path with env_id appended when envId is provided.
// @DATA_CONTRACT Input(dashboardRef, suffix, envId?) -> Output(string)
function buildDashboardRepoEndpoint(dashboardRef, suffix, envId = null) { function buildDashboardRepoEndpoint(dashboardRef, suffix, envId = null) {
const encodedRef = encodeURIComponent(String(dashboardRef)); const encodedRef = encodeURIComponent(String(dashboardRef));
const endpoint = `${API_BASE}/repositories/${encodedRef}${suffix}`; const endpoint = `${API_BASE}/repositories/${encodedRef}${suffix}`;
@@ -22,140 +19,113 @@ function buildDashboardRepoEndpoint(dashboardRef, suffix, envId = null) {
const sep = endpoint.includes('?') ? '&' : '?'; const sep = endpoint.includes('?') ? '&' : '?';
return `${endpoint}${sep}env_id=${encodeURIComponent(String(envId))}`; return `${endpoint}${sep}env_id=${encodeURIComponent(String(envId))}`;
} }
// #endregion buildDashboardRepoEndpoint
// #region gitService:Action [TYPE Function] // #region gitService [C:3] [TYPE Object]
// @BRIEF Exported Git API client with methods for all repository and config operations.
// @RELATION CALLS -> [ApiModule.requestApi]
export const gitService = { export const gitService = {
/**
* #region getConfigs:Function [TYPE Function] // #region getConfigs [C:2] [TYPE Function]
* @purpose Fetches all Git server configurations. // @BRIEF Fetch all Git server configurations.
* @pre User must be authenticated. // @PRE User is authenticated (handled by ApiModule auth headers).
* @post Returns a list of Git server configurations. // @POST Returns list of GitServerConfig objects.
* @returns {Promise<Array>} List of configs. // @DATA_CONTRACT None -> Array<GitServerConfig>
*/
async getConfigs() { async getConfigs() {
console.log('[getConfigs][Action] Fetching Git configs'); console.log('[getConfigs][Action] Fetching Git configs');
return requestApi(`${API_BASE}/config`); return requestApi(`${API_BASE}/config`);
}, },
// #endregion getConfigs
/** // #region createConfig [C:2] [TYPE Function]
* #region createConfig:Function [TYPE Function] // @BRIEF Create a new Git server configuration.
* @purpose Creates a new Git server configuration. // @PRE config object is valid with required fields.
* @pre Config object must be valid. // @POST New config is persisted and returned.
* @post New config is created and returned. // @DATA_CONTRACT Input(GitServerConfig) -> Output(GitServerConfig)
* @param {Object} config - Configuration details.
* @returns {Promise<Object>} Created config.
*/
async createConfig(config) { async createConfig(config) {
console.log('[createConfig][Action] Creating Git config'); console.log('[createConfig][Action] Creating Git config');
return requestApi(`${API_BASE}/config`, 'POST', config); return requestApi(`${API_BASE}/config`, 'POST', config);
}, },
// #endregion createConfig
/** // #region deleteConfig [C:2] [TYPE Function]
* #region deleteConfig:Function [TYPE Function] // @BRIEF Delete an existing Git server configuration.
* @purpose Deletes an existing Git server configuration. // @PRE configId exists in the backend.
* @pre configId must exist. // @POST Config is removed from the backend.
* @post Config is deleted from the backend.
* @param {string} configId - ID of the config to delete.
* @returns {Promise<Object>} Result of deletion.
*/
async deleteConfig(configId) { async deleteConfig(configId) {
console.log(`[deleteConfig][Action] Deleting Git config ${configId}`); console.log(`[deleteConfig][Action] Deleting Git config ${configId}`);
return requestApi(`${API_BASE}/config/${configId}`, 'DELETE'); return requestApi(`${API_BASE}/config/${configId}`, 'DELETE');
}, },
// #endregion deleteConfig
/** // #region updateConfig [C:2] [TYPE Function]
* #region updateConfig:Function [TYPE Function] // @BRIEF Update an existing Git server configuration.
* @purpose Updates an existing Git server configuration. // @PRE configId exists and configData contains valid fields.
* @pre configId must exist and configData must be valid. // @POST Config is updated and returned.
* @post Config is updated and returned.
* @param {string} configId - ID of the config to update.
* @param {Object} config - Updated configuration details.
* @returns {Promise<Object>} Updated config.
*/
async updateConfig(configId, config) { async updateConfig(configId, config) {
console.log(`[updateConfig][Action] Updating Git config ${configId}`); console.log(`[updateConfig][Action] Updating Git config ${configId}`);
return requestApi(`${API_BASE}/config/${configId}`, 'PUT', config); return requestApi(`${API_BASE}/config/${configId}`, 'PUT', config);
}, },
// #endregion updateConfig
/** // #region testConnection [C:2] [TYPE Function]
* #region testConnection:Function [TYPE Function] // @BRIEF Test connection to a Git server with provided credentials.
* @purpose Tests the connection to a Git server with provided credentials. // @PRE config contains valid URL and PAT.
* @pre Config must contain valid URL and PAT. // @POST Returns connection status (success/failure).
* @post Returns connection status (success/failure). // @DATA_CONTRACT Input(GitServerConfig) -> Output({status: string})
* @param {Object} config - Configuration to test.
* @returns {Promise<Object>} Connection test result.
*/
async testConnection(config) { async testConnection(config) {
console.log('[testConnection][Action] Testing Git connection'); console.log('[testConnection][Action] Testing Git connection');
return requestApi(`${API_BASE}/config/test`, 'POST', config); return requestApi(`${API_BASE}/config/test`, 'POST', config);
}, },
// #endregion testConnection
/** // #region listGiteaRepositories [C:2] [TYPE Function]
* #region listGiteaRepositories:Function [TYPE Function] // @BRIEF List repositories on Gitea for a saved Git configuration.
* @purpose Lists repositories on Gitea for a saved Git configuration. // @PRE configId references a GITEA provider config.
* @pre configId must reference a GITEA config. // @POST Returns array of Gitea repository metadata.
* @post Returns repository metadata.
* @param {string} configId - Git configuration ID.
* @returns {Promise<Array>} List of Gitea repositories.
*/
async listGiteaRepositories(configId) { async listGiteaRepositories(configId) {
console.log(`[listGiteaRepositories][Action] Listing Gitea repositories for config ${configId}`); console.log(`[listGiteaRepositories][Action] Listing Gitea repositories for config ${configId}`);
return requestApi(`${API_BASE}/config/${configId}/gitea/repos`); return requestApi(`${API_BASE}/config/${configId}/gitea/repos`);
}, },
// #endregion listGiteaRepositories
/** // #region createGiteaRepository [C:2] [TYPE Function]
* #region createGiteaRepository:Function [TYPE Function] // @BRIEF Create a repository on Gitea for a saved Git configuration.
* @purpose Creates a new repository on Gitea for a saved Git configuration. // @PRE configId references a GITEA config; payload has non-empty name.
* @pre configId must reference a GITEA config. // @POST Repository created on Gitea; returns created repo metadata.
* @post Repository is created on Gitea.
* @param {string} configId - Git configuration ID.
* @param {Object} payload - {name, private, description, auto_init, default_branch}
* @returns {Promise<Object>} Created repository payload.
*/
async createGiteaRepository(configId, payload) { async createGiteaRepository(configId, payload) {
console.log(`[createGiteaRepository][Action] Creating Gitea repository ${payload?.name} for config ${configId}`); console.log(`[createGiteaRepository][Action] Creating Gitea repository ${payload?.name} for config ${configId}`);
return requestApi(`${API_BASE}/config/${configId}/gitea/repos`, 'POST', payload); return requestApi(`${API_BASE}/config/${configId}/gitea/repos`, 'POST', payload);
}, },
// #endregion createGiteaRepository
/** // #region createRemoteRepository [C:2] [TYPE Function]
* #region createRemoteRepository:Function [TYPE Function] // @BRIEF Create repository on remote provider selected by Git config (idempotent: returns existing on 409).
* @purpose Creates repository on remote provider selected by Git config. // @PRE configId exists and points to supported provider; payload has non-empty name.
* @pre configId exists and points to supported provider config. // @POST Remote repository created (or existing returned); normalized payload returned.
* @post Remote repository created and normalized payload returned. // @DATA_CONTRACT Input(configId, RemoteRepoCreatePayload) -> Output(RemoteRepoSchema)
* @param {string} configId - Git configuration ID.
* @param {Object} payload - {name, private, description, auto_init, default_branch}
* @returns {Promise<Object>} Created remote repository payload.
*/
async createRemoteRepository(configId, payload) { async createRemoteRepository(configId, payload) {
console.log(`[createRemoteRepository][Action] Creating remote repository ${payload?.name} for config ${configId}`); console.log(`[createRemoteRepository][Action] Creating remote repository ${payload?.name} for config ${configId}`);
return requestApi(`${API_BASE}/config/${configId}/repositories`, 'POST', payload); return requestApi(`${API_BASE}/config/${configId}/repositories`, 'POST', payload);
}, },
// #endregion createRemoteRepository
/** // #region deleteGiteaRepository [C:2] [TYPE Function]
* #region deleteGiteaRepository:Function [TYPE Function] // @BRIEF Delete a repository on Gitea for a saved Git configuration.
* @purpose Deletes a repository on Gitea for a saved Git configuration. // @PRE configId references a GITEA config; owner and repoName are non-empty.
* @pre configId must reference a GITEA config. // @POST Repository deleted on Gitea server.
* @post Repository is deleted on Gitea.
* @param {string} configId - Git configuration ID.
* @param {string} owner - Repository owner.
* @param {string} repoName - Repository name.
* @returns {Promise<Object>} Deletion result.
*/
async deleteGiteaRepository(configId, owner, repoName) { async deleteGiteaRepository(configId, owner, repoName) {
console.log(`[deleteGiteaRepository][Action] Deleting Gitea repository ${owner}/${repoName} for config ${configId}`); console.log(`[deleteGiteaRepository][Action] Deleting Gitea repository ${owner}/${repoName} for config ${configId}`);
return requestApi(`${API_BASE}/config/${configId}/gitea/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}`, 'DELETE'); return requestApi(`${API_BASE}/config/${configId}/gitea/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}`, 'DELETE');
}, },
// #endregion deleteGiteaRepository
/** // #region initRepository [C:3] [TYPE Function]
* #region initRepository:Function [TYPE Function] // @BRIEF Initialize or clone a Git repository for a dashboard.
* @purpose Initializes or clones a Git repository for a dashboard. // @PRE dashboardRef is non-empty; configId and remoteUrl are valid; envId required when ref is slug.
* @pre Dashboard must exist and config_id must be valid. // @POST Repository initialized on the backend; GitRepository record created.
* @post Repository is initialized on the backend. // @DATA_CONTRACT Input(dashboardRef, configId, remoteUrl, envId?) -> Output({status: string, message: string})
* @param {string|number} dashboardRef - Dashboard slug or id. // @RELATION CALLS -> [ApiModule.requestApi]
* @param {string} configId - ID of the Git config.
* @param {string} remoteUrl - URL of the remote repository.
* @returns {Promise<Object>} Initialization result.
*/
async initRepository(dashboardRef, configId, remoteUrl, envId = null) { async initRepository(dashboardRef, configId, remoteUrl, envId = null) {
console.log(`[initRepository][Action] Initializing repo for dashboard ${dashboardRef}`); console.log(`[initRepository][Action] Initializing repo for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/init', envId), 'POST', { return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/init', envId), 'POST', {
@@ -163,43 +133,32 @@ export const gitService = {
remote_url: remoteUrl remote_url: remoteUrl
}); });
}, },
// #endregion initRepository
/** // #region getRepositoryBinding [C:2] [TYPE Function]
* #region getRepositoryBinding:Function [TYPE Function] // @BRIEF Fetch repository binding metadata (config/provider) for a dashboard.
* @purpose Fetches repository binding metadata (config/provider) for dashboard. // @PRE Repository should be initialized for the dashboard.
* @pre Repository should be initialized for dashboard. // @POST Returns provider, config_id, remote_url, local_path.
* @post Returns provider and config details for current repository.
* @param {string|number} dashboardRef - Dashboard slug or id.
* @returns {Promise<Object>} Repository binding payload.
*/
async getRepositoryBinding(dashboardRef, envId = null) { async getRepositoryBinding(dashboardRef, envId = null) {
console.log(`[getRepositoryBinding][Action] Fetching repository binding for dashboard ${dashboardRef}`); console.log(`[getRepositoryBinding][Action] Fetching repository binding for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '', envId)); return requestApi(buildDashboardRepoEndpoint(dashboardRef, '', envId));
}, },
// #endregion getRepositoryBinding
/** // #region getBranches [C:2] [TYPE Function]
* #region getBranches:Function [TYPE Function] // @BRIEF List all branches for a dashboard's repository.
* @purpose Retrieves the list of branches for a dashboard's repository. // @PRE Repository must be initialized; envId required when dashboardRef is a slug.
* @pre Repository must be initialized. // @POST Returns array of BranchSchema objects.
* @post Returns a list of branches.
* @param {string|number} dashboardRef - Dashboard slug or id.
* @returns {Promise<Array>} List of branches.
*/
async getBranches(dashboardRef, envId = null) { async getBranches(dashboardRef, envId = null) {
console.log(`[getBranches][Action] Fetching branches for dashboard ${dashboardRef}`); console.log(`[getBranches][Action] Fetching branches for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/branches', envId)); return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/branches', envId));
}, },
// #endregion getBranches
/** // #region createBranch [C:2] [TYPE Function]
* #region createBranch:Function [TYPE Function] // @BRIEF Create a new branch in the dashboard's repository.
* @purpose Creates a new branch in the dashboard's repository. // @PRE Source branch exists; name is non-empty; envId required when ref is slug.
* @pre Source branch must exist. // @POST New branch is created on the backend.
* @post New branch is created.
* @param {number} dashboardId - ID of the dashboard.
* @param {string} name - New branch name.
* @param {string} fromBranch - Source branch name.
* @returns {Promise<Object>} Creation result.
*/
async createBranch(dashboardRef, name, fromBranch, envId = null) { async createBranch(dashboardRef, name, fromBranch, envId = null) {
console.log(`[createBranch][Action] Creating branch ${name} for dashboard ${dashboardRef}`); console.log(`[createBranch][Action] Creating branch ${name} for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/branches', envId), 'POST', { return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/branches', envId), 'POST', {
@@ -207,192 +166,145 @@ export const gitService = {
from_branch: fromBranch from_branch: fromBranch
}); });
}, },
// #endregion createBranch
/** // #region checkoutBranch [C:2] [TYPE Function]
* #region checkoutBranch:Function [TYPE Function] // @BRIEF Switch the repository to a different branch.
* @purpose Switches the repository to a different branch. // @PRE Target branch exists; envId required when ref is slug.
* @pre Target branch must exist. // @POST Repository HEAD is moved to the target branch.
* @post Repository head is moved to the target branch.
* @param {number} dashboardId - ID of the dashboard.
* @param {string} name - Branch name to checkout.
* @returns {Promise<Object>} Checkout result.
*/
async checkoutBranch(dashboardRef, name, envId = null) { async checkoutBranch(dashboardRef, name, envId = null) {
console.log(`[checkoutBranch][Action] Checking out branch ${name} for dashboard ${dashboardRef}`); console.log(`[checkoutBranch][Action] Checking out branch ${name} for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/checkout', envId), 'POST', { name }); return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/checkout', envId), 'POST', { name });
}, },
// #endregion checkoutBranch
/** // #region commit [C:2] [TYPE Function]
* #region commit:Function [TYPE Function] // @BRIEF Stage and commit changes to the repository.
* @purpose Stages and commits changes to the repository. // @PRE message is non-empty; envId required when ref is slug.
* @pre Message must not be empty. // @POST Changes are committed to the current branch.
* @post Changes are committed to the current branch.
* @param {number} dashboardId - ID of the dashboard.
* @param {string} message - Commit message.
* @param {Array} files - Optional list of files to commit.
* @returns {Promise<Object>} Commit result.
*/
async commit(dashboardRef, message, files, envId = null) { async commit(dashboardRef, message, files, envId = null) {
console.log(`[commit][Action] Committing changes for dashboard ${dashboardRef}`); console.log(`[commit][Action] Committing changes for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/commit', envId), 'POST', { message, files }); return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/commit', envId), 'POST', { message, files });
}, },
// #endregion commit
/** // #region push [C:2] [TYPE Function]
* #region push:Function [TYPE Function] // @BRIEF Push local commits to the remote repository.
* @purpose Pushes local commits to the remote repository. // @PRE Remote 'origin' is configured and accessible; envId required when ref is slug.
* @pre Remote must be configured and accessible. // @POST Remote is updated with local commits.
* @post Remote is updated with local commits.
* @param {number} dashboardId - ID of the dashboard.
* @returns {Promise<Object>} Push result.
*/
async push(dashboardRef, envId = null) { async push(dashboardRef, envId = null) {
console.log(`[push][Action] Pushing changes for dashboard ${dashboardRef}`); console.log(`[push][Action] Pushing changes for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/push', envId), 'POST'); return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/push', envId), 'POST');
}, },
// #endregion push
/** // #region deleteRepository [C:2] [TYPE Function]
* #region deleteRepository:Function [TYPE Function] // @BRIEF Delete local repository binding and workspace for a dashboard.
* @purpose Deletes local repository binding and workspace for dashboard. // @PRE dashboardRef resolves on backend; envId required when ref is slug.
* @pre Dashboard reference must resolve on backend. // @POST Repository record and local folder are removed.
* @post Repository record and local folder are removed.
* @param {string|number} dashboardRef - Dashboard slug or id.
* @returns {Promise<Object>} Deletion result.
*/
async deleteRepository(dashboardRef, envId = null) { async deleteRepository(dashboardRef, envId = null) {
console.log(`[deleteRepository][Action] Deleting repository for dashboard ${dashboardRef}`); console.log(`[deleteRepository][Action] Deleting repository for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '', envId), 'DELETE'); return requestApi(buildDashboardRepoEndpoint(dashboardRef, '', envId), 'DELETE');
}, },
// #endregion deleteRepository
/** // #region pull [C:2] [TYPE Function]
* #region pull:Function [TYPE Function] // @BRIEF Pull changes from the remote repository.
* @purpose Pulls changes from the remote repository. // @PRE Remote 'origin' is configured and accessible; envId required when ref is slug.
* @pre Remote must be configured and accessible. // @POST Local repository is updated with remote changes.
* @post Local repository is updated with remote changes.
* @param {number} dashboardId - ID of the dashboard.
* @returns {Promise<Object>} Pull result.
*/
async pull(dashboardRef, envId = null) { async pull(dashboardRef, envId = null) {
console.log(`[pull][Action] Pulling changes for dashboard ${dashboardRef}`); console.log(`[pull][Action] Pulling changes for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/pull', envId), 'POST'); return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/pull', envId), 'POST');
}, },
// #endregion pull
/** // #region getMergeStatus [C:2] [TYPE Function]
* #region getMergeStatus:Function [TYPE Function] // @BRIEF Fetch unfinished-merge status for a repository.
* @purpose Retrieves unfinished-merge status for repository. // @PRE Repository must exist; envId required when ref is slug.
* @pre Repository must exist. // @POST Returns merge status payload with has_unfinished_merge flag.
* @post Returns merge status payload.
* @param {string|number} dashboardRef - Dashboard slug or id.
* @returns {Promise<Object>} Merge status details.
*/
async getMergeStatus(dashboardRef, envId = null) { async getMergeStatus(dashboardRef, envId = null) {
console.log(`[getMergeStatus][Action] Fetching merge status for dashboard ${dashboardRef}`); console.log(`[getMergeStatus][Action] Fetching merge status for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/status', envId)); return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/status', envId));
}, },
// #endregion getMergeStatus
/** // #region getMergeConflicts [C:2] [TYPE Function]
* #region getMergeConflicts:Function [TYPE Function] // @BRIEF List merge conflicts for a repository with unresolved merge.
* @purpose Retrieves merge conflicts list for repository. // @PRE Unfinished merge is in progress; envId required when ref is slug.
* @pre Unfinished merge should be in progress. // @POST Returns array of conflict file entries with mine/theirs previews.
* @post Returns conflict files with mine/theirs previews.
* @param {string|number} dashboardRef - Dashboard slug or id.
* @returns {Promise<Array>} List of conflict files.
*/
async getMergeConflicts(dashboardRef, envId = null) { async getMergeConflicts(dashboardRef, envId = null) {
console.log(`[getMergeConflicts][Action] Fetching merge conflicts for dashboard ${dashboardRef}`); console.log(`[getMergeConflicts][Action] Fetching merge conflicts for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/conflicts', envId)); return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/conflicts', envId));
}, },
// #endregion getMergeConflicts
/** // #region resolveMergeConflicts [C:2] [TYPE Function]
* #region resolveMergeConflicts:Function [TYPE Function] // @BRIEF Apply conflict resolution strategies and stage resolved files.
* @purpose Applies conflict resolution strategies and stages resolved files. // @PRE resolutions array contains file_path and resolution entries; envId required when ref is slug.
* @pre resolutions contains file_path/resolution entries. // @POST Conflicts are resolved and staged.
* @post Conflicts are resolved and staged.
* @param {string|number} dashboardRef - Dashboard slug or id.
* @param {Array} resolutions - Resolution entries.
* @returns {Promise<Object>} Resolve result.
*/
async resolveMergeConflicts(dashboardRef, resolutions, envId = null) { async resolveMergeConflicts(dashboardRef, resolutions, envId = null) {
console.log(`[resolveMergeConflicts][Action] Resolving ${Array.isArray(resolutions) ? resolutions.length : 0} conflicts for dashboard ${dashboardRef}`); console.log(`[resolveMergeConflicts][Action] Resolving ${Array.isArray(resolutions) ? resolutions.length : 0} conflicts for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/resolve', envId), 'POST', { return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/resolve', envId), 'POST', {
resolutions: Array.isArray(resolutions) ? resolutions : [] resolutions: Array.isArray(resolutions) ? resolutions : []
}); });
}, },
// #endregion resolveMergeConflicts
/** // #region abortMerge [C:2] [TYPE Function]
* #region abortMerge:Function [TYPE Function] // @BRIEF Abort current unfinished merge.
* @purpose Aborts current unfinished merge. // @PRE Repository exists; envId required when ref is slug.
* @pre Repository exists. // @POST Merge state is aborted or reported as absent.
* @post Merge state is aborted or reported as absent.
* @param {string|number} dashboardRef - Dashboard slug or id.
* @returns {Promise<Object>} Abort operation result.
*/
async abortMerge(dashboardRef, envId = null) { async abortMerge(dashboardRef, envId = null) {
console.log(`[abortMerge][Action] Aborting merge for dashboard ${dashboardRef}`); console.log(`[abortMerge][Action] Aborting merge for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/abort', envId), 'POST'); return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/abort', envId), 'POST');
}, },
// #endregion abortMerge
/** // #region continueMerge [C:2] [TYPE Function]
* #region continueMerge:Function [TYPE Function] // @BRIEF Finalize unfinished merge by creating a merge commit.
* @purpose Finalizes unfinished merge by creating merge commit. // @PRE All conflicts are resolved; envId required when ref is slug.
* @pre All conflicts are resolved. // @POST Merge commit is created.
* @post Merge commit is created.
* @param {string|number} dashboardRef - Dashboard slug or id.
* @param {string} message - Optional commit message.
* @returns {Promise<Object>} Continue result.
*/
async continueMerge(dashboardRef, message = '', envId = null) { async continueMerge(dashboardRef, message = '', envId = null) {
console.log(`[continueMerge][Action] Continuing merge for dashboard ${dashboardRef}`); console.log(`[continueMerge][Action] Continuing merge for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/continue', envId), 'POST', { return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/continue', envId), 'POST', {
message: String(message || '').trim() || null message: String(message || '').trim() || null
}); });
}, },
// #endregion continueMerge
/** // #region getEnvironments [C:2] [TYPE Function]
* #region getEnvironments:Function [TYPE Function] // @BRIEF Fetch available deployment environments from Git config.
* @purpose Retrieves available deployment environments. // @POST Returns list of environment objects.
* @post Returns a list of environments.
* @returns {Promise<Array>} List of environments.
*/
async getEnvironments() { async getEnvironments() {
console.log('[getEnvironments][Action] Fetching environments'); console.log('[getEnvironments][Action] Fetching environments');
return requestApi(`${API_BASE}/environments`); return requestApi(`${API_BASE}/environments`);
}, },
// #endregion getEnvironments
/** // #region deploy [C:2] [TYPE Function]
* #region deploy:Function [TYPE Function] // @BRIEF Deploy a dashboard to a target environment.
* @purpose Deploys a dashboard to a target environment. // @PRE Environment must be active and accessible; envId required when ref is slug.
* @pre Environment must be active and accessible. // @POST Dashboard is imported into target Superset instance.
* @post Dashboard is imported into the target Superset instance.
* @param {number} dashboardId - ID of the dashboard.
* @param {string} environmentId - ID of the target environment.
* @returns {Promise<Object>} Deployment result.
*/
async deploy(dashboardRef, environmentId, envId = null) { async deploy(dashboardRef, environmentId, envId = null) {
console.log(`[deploy][Action] Deploying dashboard ${dashboardRef} to environment ${environmentId}`); console.log(`[deploy][Action] Deploying dashboard ${dashboardRef} to environment ${environmentId}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/deploy', envId), 'POST', { return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/deploy', envId), 'POST', {
environment_id: environmentId environment_id: environmentId
}); });
}, },
// #endregion deploy
/** // #region getHistory [C:2] [TYPE Function]
* #region getHistory:Function [TYPE Function] // @BRIEF Fetch commit history for a dashboard repository.
* @purpose Retrieves the commit history for a dashboard. // @POST Returns list of commits with author, date, message.
* @param {number} dashboardId - ID of the dashboard.
* @param {number} limit - Maximum number of commits to return.
* @returns {Promise<Array>} List of commits.
*/
async getHistory(dashboardRef, limit = 50, envId = null) { async getHistory(dashboardRef, limit = 50, envId = null) {
console.log(`[getHistory][Action] Fetching history for dashboard ${dashboardRef}`); console.log(`[getHistory][Action] Fetching history for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, `/history?limit=${limit}`, envId)); return requestApi(buildDashboardRepoEndpoint(dashboardRef, `/history?limit=${limit}`, envId));
}, },
// #endregion getHistory
/** // #region sync [C:2] [TYPE Function]
* #region sync:Function [TYPE Function] // @BRIEF Synchronize local dashboard state with Git repository.
* @purpose Synchronizes the local dashboard state with the Git repository. // @POST Dashboard state is synced to Git workspace.
* @param {number} dashboardId - ID of the dashboard.
* @param {string|null} sourceEnvId - Optional source environment ID.
* @returns {Promise<Object>} Sync result.
*/
async sync(dashboardRef, sourceEnvId = null, envId = null) { async sync(dashboardRef, sourceEnvId = null, envId = null) {
console.log(`[sync][Action] Syncing dashboard ${dashboardRef}`); console.log(`[sync][Action] Syncing dashboard ${dashboardRef}`);
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -402,45 +314,34 @@ export const gitService = {
const endpoint = `${API_BASE}/repositories/${encodeURIComponent(String(dashboardRef))}/sync${query ? `?${query}` : ''}`; const endpoint = `${API_BASE}/repositories/${encodeURIComponent(String(dashboardRef))}/sync${query ? `?${query}` : ''}`;
return requestApi(endpoint, 'POST'); return requestApi(endpoint, 'POST');
}, },
// #endregion sync
/** // #region getStatus [C:2] [TYPE Function]
* #region getStatus:Function [TYPE Function] // @BRIEF Fetch current Git status for a dashboard repository (dirty files, branch info).
* @purpose Fetches the current Git status for a dashboard repository. // @PRE Repository path exists on disk; envId required when ref is slug.
* @pre dashboardId must be a valid integer. // @POST Returns status payload with is_dirty, staged_files, modified_files, current_branch.
* @post Returns a status object with dirty files and branch info.
* @param {number} dashboardId - The ID of the dashboard.
* @returns {Promise<Object>} Status details.
*/
async getStatus(dashboardRef, envId = null) { async getStatus(dashboardRef, envId = null) {
console.log(`[getStatus][Action] Fetching status for dashboard ${dashboardRef}`); console.log(`[getStatus][Action] Fetching status for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/status', envId)); return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/status', envId));
}, },
// #endregion getStatus
/** // #region getStatusesBatch [C:2] [TYPE Function]
* #region getStatusesBatch:Function [TYPE Function] // @BRIEF Fetch Git statuses for multiple dashboards in one batch request.
* @purpose Fetches Git statuses for multiple dashboards in a single request. // @PRE dashboardIds is a non-empty array of numeric IDs.
* @pre dashboardIds must be an array of dashboard IDs. // @POST Returns a map of dashboard_id -> status payload (with NO_REPO for uninitialized).
* @post Returns a map of dashboard_id -> status payload.
* @param {Array<number>} dashboardIds - Dashboard IDs.
* @returns {Promise<Object>} Batch status response.
*/
async getStatusesBatch(dashboardIds) { async getStatusesBatch(dashboardIds) {
console.log(`[getStatusesBatch][Action] Fetching statuses for ${dashboardIds.length} dashboards`); console.log(`[getStatusesBatch][Action] Fetching statuses for ${dashboardIds.length} dashboards`);
return requestApi(`${API_BASE}/repositories/status/batch`, 'POST', { return requestApi(`${API_BASE}/repositories/status/batch`, 'POST', {
dashboard_ids: dashboardIds dashboard_ids: dashboardIds
}); });
}, },
// #endregion getStatusesBatch
/** // #region getDiff [C:2] [TYPE Function]
* #region getDiff:Function [TYPE Function] // @BRIEF Fetch Git diff for a dashboard repository (staged or unstaged).
* @purpose Retrieves the diff for specific files or the whole repository. // @PRE Repository path exists; envId required when ref is slug.
* @pre dashboardId must be a valid integer. // @POST Returns unified diff string.
* @post Returns the Git diff string.
* @param {number} dashboardId - The ID of the dashboard.
* @param {string|null} filePath - Optional specific file path.
* @param {boolean} staged - Whether to show staged changes.
* @returns {Promise<string>} The diff content.
*/
async getDiff(dashboardRef, filePath = null, staged = false, envId = null) { async getDiff(dashboardRef, filePath = null, staged = false, envId = null) {
console.log(`[getDiff][Action] Fetching diff for dashboard ${dashboardRef} (file: ${filePath}, staged: ${staged})`); console.log(`[getDiff][Action] Fetching diff for dashboard ${dashboardRef} (file: ${filePath}, staged: ${staged})`);
let endpoint = `${API_BASE}/repositories/${encodeURIComponent(String(dashboardRef))}/diff`; let endpoint = `${API_BASE}/repositories/${encodeURIComponent(String(dashboardRef))}/diff`;
@@ -451,23 +352,17 @@ export const gitService = {
if (params.toString()) endpoint += `?${params.toString()}`; if (params.toString()) endpoint += `?${params.toString()}`;
return requestApi(endpoint); return requestApi(endpoint);
}, },
// #endregion getDiff
/** // #region promote [C:2] [TYPE Function]
* #region promote:Function [TYPE Function] // @BRIEF Promote changes between branches via MR or direct merge.
* @purpose Promotes changes between branches via MR or direct merge. // @PRE Dashboard repository initialized; from_branch and to_branch differ; envId required when ref is slug.
* @pre Dashboard repository must be initialized. // @POST Returns MR URL (MR mode) or merge status (direct mode).
* @post Returns promotion metadata (MR URL or direct merge status).
* @param {string|number} dashboardRef - Dashboard slug or id.
* @param {Object} payload - {from_branch,to_branch,mode,title,description,reason,draft,remove_source_branch}
* @param {string|null} envId - Environment id for slug resolution.
* @returns {Promise<Object>} Promotion result.
*/
async promote(dashboardRef, payload, envId = null) { async promote(dashboardRef, payload, envId = null) {
console.log(`[promote][Action] Promoting ${payload?.from_branch} -> ${payload?.to_branch} for dashboard ${dashboardRef} mode=${payload?.mode}`); console.log(`[promote][Action] Promoting ${payload?.from_branch} -> ${payload?.to_branch} for dashboard ${dashboardRef} mode=${payload?.mode}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/promote', envId), 'POST', payload); return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/promote', envId), 'POST', payload);
} }
// #endregion promote
}; };
// #endregion gitService:Action // #endregion gitService
// #endregion GitServiceClient:Module
// #endregion GitServiceClient // #endregion GitServiceClient