fix
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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)
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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] -->
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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">
|
||||||
Нет изменений для коммита
|
Нет изменений для коммита
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
214
frontend/src/lib/components/ui/DatabaseSearchCombobox.svelte
Normal file
214
frontend/src/lib/components/ui/DatabaseSearchCombobox.svelte
Normal 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 -->
|
||||||
204
frontend/src/lib/components/ui/DatasetSearchCombobox.svelte
Normal file
204
frontend/src/lib/components/ui/DatasetSearchCombobox.svelte
Normal 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 -->
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user