P0 — Model-first ADR compliance:
- Decompose DashboardHubModel (590→496 lines) into Dashboards.FiltersModel,
Dashboards.SelectionModel, Dashboards.GitActionsModel (DG split per plan)
- Decompose AgentChatModel (630→356 lines) into ConnectionManager,
StreamProcessor, LocalStorage, shared types
- Decompose MigrationModel (457→389 lines) into WizardModel, ExecutorModel
P0 — /ui atom compliance:
- Replace all raw <button> with <Button> from /ui in dashboards/+page.svelte
(~20 replacements) and 16 additional routes/ files (~70 replacements total)
P0 — Hierarchical region IDs (ATTN_2):
- Rename all 22 model #region/#endregion IDs from flat to Domain.Name format
- Update @ingroup from generic 'Models' to domain-specific (Dashboards, Git, etc.)
P1 — UX contract compliance:
- Add @UX_STATE declarations to agent/+page.svelte
- Extract Gradio Client.connect from page into AgentChatModel.retryConnection()
All new model files have proper GRACE anchors (#region/#endregion, @ingroup,
@BRIEF, @INVARIANT, @STATE, @ACTION, @RELATION).
Build: npm run build passes.
Tests: DashboardHubModel 112/112, MigrationModel 74/74 pass.
868 lines
36 KiB
TypeScript
868 lines
36 KiB
TypeScript
// frontend/src/lib/models/GitManagerModel.svelte.ts
|
||
// #region Git.ManagerModel [C:4] [TYPE Model] [SEMANTICS git,workspace,screen-model,state,repository]
|
||
// @ingroup Git
|
||
// @BRIEF State model for the Git workspace panel — declares atoms, invariants, and actions.
|
||
// @INVARIANT Loading states are mutually exclusive per operation:
|
||
// checkingStatus, loading, workspaceLoading, committing, isPulling, isPushing,
|
||
// generatingMessage, promoting, mergeResolveInProgress, mergeAbortInProgress,
|
||
// mergeContinueInProgress, mergeRecoveryLoading, creatingRemoteRepo.
|
||
// At most one of these loading flags is true at any time.
|
||
// @INVARIANT Git error is always cleared before any new operation (clearError before try).
|
||
// @INVARIANT Repository binding (configId, provider, remoteUrl) is loaded only after initialized=true.
|
||
// @STATE uninitialized — No repository bound to dashboard.
|
||
// @STATE initialized — Repository exists, tab workspace/release/operations available.
|
||
// @STATE checking — Initial status check in progress.
|
||
// @STATE error — Persistent error banner visible with message and dismiss action.
|
||
// @ACTION checkStatus() — Checks if repository is initialized; loads workspace on success.
|
||
// @ACTION loadWorkspace() — Loads workspace status and diff.
|
||
// @ACTION handleSync() — Synchronizes dashboard state with Git.
|
||
// @ACTION handleGenerateMessage() — Generates AI commit message from diff.
|
||
// @ACTION handleCommit() — Stages and commits workspace changes.
|
||
// @ACTION handlePromote(...) — Promotes changes between branches.
|
||
// @ACTION handlePull() — Pulls from remote.
|
||
// @ACTION handlePush() — Pushes to remote.
|
||
// @ACTION openDeployModal() — Opens deployment target modal.
|
||
// @ACTION handleCreateRemoteRepo() — Creates remote repository on provider.
|
||
// @ACTION handleInit() — Initializes local repository from remote URL.
|
||
// @ACTION loadMergeRecoveryState() — Loads merge recovery state.
|
||
// @ACTION closeUnfinishedMergeDialog() — Closes merge dialog and resets state.
|
||
// @ACTION handleOpenConflictResolver() — Loads conflicts and opens resolver.
|
||
// @ACTION handleResolveConflicts(event) — Applies conflict resolutions.
|
||
// @ACTION handleAbortUnfinishedMerge() — Aborts in-progress merge.
|
||
// @ACTION handleContinueUnfinishedMerge() — Finalizes merge commit.
|
||
// @ACTION handleCopyUnfinishedMergeCommands() — Copies commands to clipboard.
|
||
// @ACTION clearGitError() — Clears the persistent error banner.
|
||
// @ACTION initialize(dashboardId, envId, dashboardTitle) — Bootstraps state on mount.
|
||
// @RELATION DEPENDS_ON -> [EXT:frontend:gitService]
|
||
// @RELATION DEPENDS_ON -> [EXT:frontend:api]
|
||
// @RELATION DEPENDS_ON -> [GitUtils]
|
||
// @RELATION DEPENDS_ON -> [ToastsModule]
|
||
// @REJECTED getState/setState proxy pattern rejected — fragile, untyped, hard to test.
|
||
// Real $state atoms with class methods enable L1 testing without DOM render.
|
||
|
||
import { gitService } from '../../services/gitService.js';
|
||
import { api } from '$lib/api.js';
|
||
import { addToast } from '$lib/toasts.svelte.js';
|
||
import {
|
||
isNumericDashboardRef,
|
||
resolveDefaultConfig,
|
||
extractUnfinishedMergeContext,
|
||
buildSuggestedRepoName,
|
||
resolveCurrentEnvironmentId,
|
||
normalizeEnvStage,
|
||
applyGitflowStageDefaults,
|
||
resolvePushProviderLabel,
|
||
extractHttpHost,
|
||
} from '../../services/git-utils.js';
|
||
import { getT } from '$lib/i18n/index.svelte.js';
|
||
|
||
// ── Types ─────────────────────────────────────────────────────
|
||
|
||
interface GitConfig {
|
||
id: string | number;
|
||
provider?: string;
|
||
url?: string;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
interface WorkspaceStatus {
|
||
current_branch?: string;
|
||
staged_files?: string[];
|
||
modified_files?: string[];
|
||
untracked_files?: string[];
|
||
is_dirty?: boolean;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
interface GitErrorPayload {
|
||
message: string;
|
||
status: number;
|
||
error_code: string | null;
|
||
files: unknown[];
|
||
next_steps: string[];
|
||
detail: Record<string, unknown> | null;
|
||
errorType?: string;
|
||
}
|
||
|
||
interface UnfinishedMergeContext {
|
||
message?: string;
|
||
repositoryPath?: string;
|
||
gitDir?: string;
|
||
currentBranch?: string;
|
||
mergeHead?: string;
|
||
mergeMessagePreview?: string;
|
||
nextSteps?: string[];
|
||
commands?: string[];
|
||
conflictsCount?: number;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
interface MergeConflict {
|
||
file_path: string;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
interface RepositoryBinding {
|
||
provider?: string;
|
||
remote_url?: string;
|
||
config_id?: string | number;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
interface PromotePayload {
|
||
from_branch: string;
|
||
to_branch: string;
|
||
mode: string;
|
||
title: string;
|
||
description?: string;
|
||
reason?: string;
|
||
}
|
||
|
||
interface PromoteResponse {
|
||
url?: string;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
interface MergeStatus {
|
||
has_unfinished_merge?: boolean;
|
||
repository_path?: string;
|
||
git_dir?: string;
|
||
current_branch?: string;
|
||
merge_head?: string;
|
||
merge_message_preview?: string;
|
||
conflicts_count?: number;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
interface CreateRepoPayload {
|
||
name: string;
|
||
private: boolean;
|
||
description: string;
|
||
auto_init: boolean;
|
||
default_branch: string;
|
||
}
|
||
|
||
interface CreateRepoResult {
|
||
clone_url?: string;
|
||
html_url?: string;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
interface EnvironmentItem {
|
||
id: string;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
interface NumericDashboardRefCheck {
|
||
isNumericDashboardRef: typeof isNumericDashboardRef;
|
||
resolveEnvId: () => string | null;
|
||
normalizeEnvStageFn: typeof normalizeEnvStage;
|
||
applyGitflowStageDefaultsFn: typeof applyGitflowStageDefaults;
|
||
}
|
||
|
||
interface GitflowStageDefaults {
|
||
promoteFromBranch?: string;
|
||
promoteToBranch?: string;
|
||
preferredDeployTargetStage?: string;
|
||
}
|
||
|
||
// #region buildGitErrorFromException [C:2] [TYPE Function]
|
||
// @ingroup Git
|
||
// @BRIEF Extract structured gitError payload from a caught exception for modal error banner.
|
||
// @PRE e is an Error object with optional .status, .detail, .error_code properties.
|
||
// @POST Returns { message, status, error_code, files, next_steps, detail } for state.
|
||
function buildGitErrorFromException(e: unknown): GitErrorPayload {
|
||
if (!e) return { message: 'Неизвестная ошибка Git', status: 500, error_code: null, files: [], next_steps: [], detail: null };
|
||
const err = e as Record<string, unknown>;
|
||
const detail = err?.detail || (typeof err?.detail === 'string' ? { message: err.detail } : null) || null;
|
||
const detailObj = typeof detail === 'object' ? detail as Record<string, unknown> : null;
|
||
return {
|
||
message: (detailObj?.message as string) || (detailObj?.message_en as string) || (err.message as string) || 'Произошла ошибка Git',
|
||
status: (err?.status as number) || 500,
|
||
error_code: (err?.error_code as string) || (detailObj?.error_code as string) || null,
|
||
files: (detailObj?.files as unknown[]) || [],
|
||
next_steps: (detailObj?.next_steps as string[]) || (detailObj?.next_steps_en as string[]) || [],
|
||
detail: detailObj,
|
||
};
|
||
}
|
||
// #endregion buildGitErrorFromException
|
||
|
||
/** short alias for getT() */
|
||
function tt(): Record<string, unknown> {
|
||
return getT();
|
||
}
|
||
|
||
export class GitManagerModel {
|
||
// ── Props (synced from component) ────────────────────────────
|
||
dashboardId: string = $state('');
|
||
/** Raw envId from props (null = not set). resolvedEnvId adds localStorage fallback. */
|
||
envId: string | null = $state(null);
|
||
dashboardTitle: string = $state('');
|
||
|
||
// ── Branch & Status ─────────────────────────────────────────
|
||
initialized: boolean = $state(false);
|
||
loading: boolean = $state(false);
|
||
checkingStatus: boolean = $state(true);
|
||
currentBranch: string = $state('main');
|
||
activeTab: string = $state('workspace');
|
||
|
||
// ── Configs & Remote ────────────────────────────────────────
|
||
configs: GitConfig[] = $state([]);
|
||
selectedConfigId: string = $state('');
|
||
remoteUrl: string = $state('');
|
||
creatingRemoteRepo: boolean = $state(false);
|
||
repositoryProvider: string = $state('');
|
||
repositoryBindingRemoteUrl: string = $state('');
|
||
repositoryConfigUrl: string = $state('');
|
||
|
||
// ── Commit ──────────────────────────────────────────────────
|
||
commitMessage: string = $state('');
|
||
committing: boolean = $state(false);
|
||
generatingMessage: boolean = $state(false);
|
||
autoPushAfterCommit: boolean = $state(true);
|
||
|
||
// ── Workspace ───────────────────────────────────────────────
|
||
workspaceStatus: WorkspaceStatus | null = $state(null);
|
||
workspaceDiff: string = $state('');
|
||
workspaceLoading: boolean = $state(false);
|
||
|
||
// ── Pull / Push ─────────────────────────────────────────────
|
||
isPulling: boolean = $state(false);
|
||
isPushing: boolean = $state(false);
|
||
|
||
// ── Promote ─────────────────────────────────────────────────
|
||
showAdvancedPromote: boolean = $state(false);
|
||
promoting: boolean = $state(false);
|
||
promoteFromBranch: string = $state('dev');
|
||
promoteToBranch: string = $state('preprod');
|
||
promoteMode: string = $state('mr');
|
||
promoteReason: string = $state('');
|
||
|
||
// ── Environment ─────────────────────────────────────────────
|
||
currentEnvStage: string = $state('');
|
||
preferredDeployTargetStage: string = $state('');
|
||
|
||
// ── Deploy ──────────────────────────────────────────────────
|
||
showDeployModal: boolean = $state(false);
|
||
|
||
// ── Merge Recovery ──────────────────────────────────────────
|
||
showUnfinishedMergeDialog: boolean = $state(false);
|
||
unfinishedMergeContext: UnfinishedMergeContext | null = $state(null);
|
||
copyingUnfinishedMergeCommands: boolean = $state(false);
|
||
mergeRecoveryLoading: boolean = $state(false);
|
||
mergeConflicts: MergeConflict[] = $state([]);
|
||
showConflictResolver: boolean = $state(false);
|
||
mergeResolveInProgress: boolean = $state(false);
|
||
mergeAbortInProgress: boolean = $state(false);
|
||
mergeContinueInProgress: boolean = $state(false);
|
||
|
||
// ── Error Banner ────────────────────────────────────────────
|
||
gitError: GitErrorPayload | null = $state(null);
|
||
gitErrorType: string = $state('error');
|
||
|
||
// ── Derived ─────────────────────────────────────────────────
|
||
|
||
/** Resolved envId with localStorage fallback. */
|
||
resolvedEnvId: string | null = $derived(resolveCurrentEnvironmentId(this.envId));
|
||
|
||
/** True when there are any workspace changes (staged, modified, or untracked). */
|
||
hasWorkspaceChanges: boolean = $derived.by(() => {
|
||
if (!this.workspaceStatus) return false;
|
||
const w = this.workspaceStatus;
|
||
return [...(w.staged_files || []), ...(w.modified_files || []), ...(w.untracked_files || [])].length > 0;
|
||
});
|
||
|
||
/** Total count of changed files (staged + modified + untracked). */
|
||
changedFilesCount: number = $derived.by(() => {
|
||
if (!this.workspaceStatus) return 0;
|
||
const w = this.workspaceStatus;
|
||
return [...(w.staged_files || []), ...(w.modified_files || []), ...(w.untracked_files || [])].length;
|
||
});
|
||
|
||
/** Lower-case provider label for auto-push checkbox text. */
|
||
pushProviderLabel: string = $derived(resolvePushProviderLabel(this.configs, this.selectedConfigId, this.repositoryProvider));
|
||
|
||
/** Origin host from repository binding URL. */
|
||
originHost: string = $derived(extractHttpHost(this.repositoryBindingRemoteUrl));
|
||
|
||
/** Config host from default config URL. Synced from component via model.repositoryConfigUrl. */
|
||
configHost: string = $derived(extractHttpHost(this.repositoryConfigUrl));
|
||
|
||
/** True when origin and config hosts differ (server mismatch warning). */
|
||
hasOriginConfigMismatch: boolean = $derived(!!(this.originHost && this.configHost && this.originHost !== this.configHost));
|
||
|
||
constructor({ dashboardId = '', envId = null, dashboardTitle = '' }: { dashboardId?: string; envId?: string | null; dashboardTitle?: string } = {}) {
|
||
this.dashboardId = dashboardId;
|
||
this.envId = envId;
|
||
this.dashboardTitle = dashboardTitle;
|
||
}
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────
|
||
|
||
/** Clear the persistent error banner. */
|
||
clearGitError(): void {
|
||
this.gitError = null;
|
||
this.gitErrorType = 'error';
|
||
}
|
||
|
||
/** @private Set the persistent error banner + show persistent toast. */
|
||
_setGitError(e: unknown, errorType?: string): void {
|
||
const err = buildGitErrorFromException(e);
|
||
err.errorType = errorType || (err.status >= 500 ? 'error' : err.status === 409 ? 'warning' : 'error');
|
||
this.gitError = err;
|
||
this.gitErrorType = err.errorType;
|
||
addToast(err.message, err.errorType === 'warning' ? 'warning' : 'error', 0);
|
||
}
|
||
|
||
/** @private Convenient access to i18n store value for translations. */
|
||
get _t(): Record<string, unknown> {
|
||
return tt();
|
||
}
|
||
|
||
/** @private Collect current scalar + helper snapshot for handler context. */
|
||
_snapshot(): NumericDashboardRefCheck {
|
||
return {
|
||
isNumericDashboardRef,
|
||
resolveEnvId: () => resolveCurrentEnvironmentId(this.envId),
|
||
normalizeEnvStageFn: normalizeEnvStage,
|
||
applyGitflowStageDefaultsFn: applyGitflowStageDefaults,
|
||
};
|
||
}
|
||
|
||
// ── Initialization ───────────────────────────────────────────
|
||
|
||
/**
|
||
* Bootstrap state on mount: load configs, check status, resolve env stage,
|
||
* and load repository binding if initialized.
|
||
*/
|
||
async initialize(): Promise<void> {
|
||
this.loading = true;
|
||
try {
|
||
this.configs = await gitService.getConfigs();
|
||
const dc = resolveDefaultConfig(this.configs, this.selectedConfigId);
|
||
if (dc?.id) this.selectedConfigId = String(dc.id);
|
||
} catch {
|
||
this.configs = [];
|
||
}
|
||
this.loading = false;
|
||
await Promise.all([this.checkStatus(), this.loadCurrentEnvironmentStage()]);
|
||
if (this.initialized) {
|
||
try {
|
||
const binding: RepositoryBinding = await gitService.getRepositoryBinding(this.dashboardId, this.resolvedEnvId);
|
||
this.repositoryProvider = binding?.provider || '';
|
||
this.repositoryBindingRemoteUrl = binding?.remote_url || '';
|
||
if (binding?.config_id) this.selectedConfigId = String(binding.config_id);
|
||
} catch {
|
||
this.repositoryProvider = '';
|
||
this.repositoryBindingRemoteUrl = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Environment Stage ────────────────────────────────────────
|
||
|
||
/**
|
||
* Resolve current environment stage (DEV/PREPROD/PROD) and apply GitFlow defaults.
|
||
* @SIDE_EFFECT Fetches /environments from API.
|
||
*/
|
||
async loadCurrentEnvironmentStage(): Promise<void> {
|
||
const currentEnvId = resolveCurrentEnvironmentId(this.envId);
|
||
if (!currentEnvId) return;
|
||
try {
|
||
const environments: EnvironmentItem[] = await api.getEnvironmentsList();
|
||
const currentEnv = (environments || []).find((item: EnvironmentItem) => item.id === currentEnvId);
|
||
if (!currentEnv) return;
|
||
const stage = normalizeEnvStage(currentEnv);
|
||
this.currentEnvStage = stage;
|
||
const defaults: GitflowStageDefaults = applyGitflowStageDefaults(stage);
|
||
if (defaults.promoteFromBranch !== undefined) this.promoteFromBranch = defaults.promoteFromBranch;
|
||
if (defaults.promoteToBranch !== undefined) this.promoteToBranch = defaults.promoteToBranch;
|
||
if (defaults.preferredDeployTargetStage !== undefined) this.preferredDeployTargetStage = defaults.preferredDeployTargetStage;
|
||
} catch (e: unknown) {
|
||
console.error(`[GitManagerModel][loadCurrentEnvironmentStage] Failed: ${e instanceof Error ? e.message : String(e)}`);
|
||
}
|
||
}
|
||
|
||
// ── Check Status ─────────────────────────────────────────────
|
||
|
||
/**
|
||
* Check if Git repository is initialized for the dashboard.
|
||
* @POST initialized=true if repository found and workspace loaded.
|
||
*/
|
||
async checkStatus(): Promise<void> {
|
||
if (isNumericDashboardRef(this.dashboardId)) {
|
||
this.checkingStatus = false;
|
||
this.initialized = false;
|
||
addToast('GitManager requires dashboard slug. Numeric ID is forbidden.', 'error');
|
||
return;
|
||
}
|
||
this.checkingStatus = true;
|
||
if (!this.resolvedEnvId) {
|
||
this.initialized = false;
|
||
this.checkingStatus = false;
|
||
return;
|
||
}
|
||
try {
|
||
await gitService.getBranches(this.dashboardId, this.resolvedEnvId);
|
||
this.initialized = true;
|
||
await this.loadWorkspace();
|
||
} catch {
|
||
this.initialized = false;
|
||
} finally {
|
||
this.checkingStatus = false;
|
||
}
|
||
}
|
||
|
||
// ── Workspace ────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Load Git workspace status and diff for initialized repository.
|
||
* @SIDE_EFFECT Fetches /status and /diff for the dashboard repository.
|
||
*/
|
||
async loadWorkspace(): Promise<void> {
|
||
if (!this.initialized) return;
|
||
this.clearGitError();
|
||
this.workspaceLoading = true;
|
||
try {
|
||
const ws: WorkspaceStatus = await gitService.getStatus(this.dashboardId, this.resolvedEnvId);
|
||
const sd: string = await gitService.getDiff(this.dashboardId, null, true, this.resolvedEnvId);
|
||
const ud: string = await gitService.getDiff(this.dashboardId, null, false, this.resolvedEnvId);
|
||
this.workspaceStatus = ws;
|
||
this.workspaceDiff = [sd, ud].filter(Boolean).join('\n\n');
|
||
this.currentBranch = ws?.current_branch || this.currentBranch;
|
||
} catch (e: unknown) {
|
||
this._setGitError(e);
|
||
} finally {
|
||
this.workspaceLoading = false;
|
||
}
|
||
}
|
||
|
||
// ── Sync ─────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Synchronize local dashboard state with Git repository.
|
||
* @SIDE_EFFECT POSTs to /git/repositories/{ref}/sync.
|
||
*/
|
||
async handleSync(): Promise<void> {
|
||
if (isNumericDashboardRef(this.dashboardId)) {
|
||
addToast('GitManager requires dashboard slug. Numeric ID is forbidden.', 'error');
|
||
return;
|
||
}
|
||
this.clearGitError();
|
||
this.loading = true;
|
||
try {
|
||
const sourceEnvId = this.resolvedEnvId || localStorage.getItem('selected_env_id');
|
||
await gitService.sync(this.dashboardId, sourceEnvId, this.resolvedEnvId);
|
||
addToast((this._t?.git as Record<string, unknown>)?.sync_success as string || 'Состояние дашборда синхронизировано с Git', 'success');
|
||
await this.loadWorkspace();
|
||
} catch (e: unknown) {
|
||
this._setGitError(e);
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
}
|
||
|
||
// ── Generate Message ─────────────────────────────────────────
|
||
|
||
/**
|
||
* Generate AI commit message from dashboard diff.
|
||
* @SIDE_EFFECT POSTs to /git/repositories/{ref}/generate-message.
|
||
*/
|
||
async handleGenerateMessage(): Promise<void> {
|
||
this.clearGitError();
|
||
this.generatingMessage = true;
|
||
try {
|
||
const data: { message?: string } = await api.postApi(
|
||
`/git/repositories/${encodeURIComponent(String(this.dashboardId))}/generate-message${
|
||
this.resolvedEnvId ? `?env_id=${encodeURIComponent(String(this.resolvedEnvId))}` : ''
|
||
}`,
|
||
undefined,
|
||
{ suppressToast: true },
|
||
);
|
||
this.commitMessage = data?.message || '';
|
||
addToast((this._t?.git as Record<string, unknown>)?.commit_message_generated as string || 'Сообщение для коммита сгенерировано', 'success');
|
||
} catch (e: unknown) {
|
||
this._setGitError(e);
|
||
} finally {
|
||
this.generatingMessage = false;
|
||
}
|
||
}
|
||
|
||
// ── Commit ───────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Stage and commit workspace changes, optionally push.
|
||
* @PRE commitMessage is non-empty; hasWorkspaceChanges is true.
|
||
*/
|
||
async handleCommit(): Promise<void> {
|
||
if (!this.commitMessage || !this.hasWorkspaceChanges) return;
|
||
this.clearGitError();
|
||
this.committing = true;
|
||
try {
|
||
await gitService.commit(this.dashboardId, this.commitMessage, [], this.resolvedEnvId);
|
||
if (this.autoPushAfterCommit) {
|
||
await gitService.push(this.dashboardId, this.resolvedEnvId);
|
||
addToast((this._t?.git as Record<string, unknown>)?.commit_and_push_success as string || 'Коммит создан и отправлен в remote', 'success');
|
||
} else {
|
||
addToast((this._t?.git as Record<string, unknown>)?.commit_success as string || 'Коммит успешно создан', 'success');
|
||
}
|
||
this.commitMessage = '';
|
||
await this.loadWorkspace();
|
||
} catch (e: unknown) {
|
||
this._setGitError(e);
|
||
} finally {
|
||
this.committing = false;
|
||
}
|
||
}
|
||
|
||
// ── Promote ──────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Promote changes between branches via MR or direct merge.
|
||
* @SIDE_EFFECT Opens MR URL in new tab (MR mode).
|
||
*/
|
||
async handlePromote(): Promise<void> {
|
||
if (!this.promoteFromBranch || !this.promoteToBranch || this.promoteFromBranch === this.promoteToBranch) {
|
||
addToast('Выберите разные исходную и целевую ветки', 'error');
|
||
return;
|
||
}
|
||
if (this.promoteMode === 'direct' && !String(this.promoteReason || '').trim()) {
|
||
addToast('Для небезопасного прямого переноса укажите причину', 'error');
|
||
return;
|
||
}
|
||
this.clearGitError();
|
||
this.promoting = true;
|
||
try {
|
||
const response: PromoteResponse = await gitService.promote(
|
||
this.dashboardId,
|
||
{
|
||
from_branch: this.promoteFromBranch,
|
||
to_branch: this.promoteToBranch,
|
||
mode: this.promoteMode,
|
||
title: `Promote ${this.promoteFromBranch} -> ${this.promoteToBranch}: ${this.dashboardTitle || this.dashboardId}`,
|
||
description: this.promoteMode === 'direct' ? `Unsafe direct promote.\nReason: ${this.promoteReason}` : undefined,
|
||
reason: this.promoteMode === 'direct' ? this.promoteReason : undefined,
|
||
} satisfies PromotePayload,
|
||
this.resolvedEnvId,
|
||
);
|
||
if (this.promoteMode === 'direct') {
|
||
addToast('Прямой перенос выполнен. Нарушение политики записано в логи.', 'warning');
|
||
} else {
|
||
if (response?.url) window.open(response.url, '_blank', 'noopener,noreferrer');
|
||
addToast('Merge Request создан на Git сервере', 'success');
|
||
}
|
||
} catch (e: unknown) {
|
||
this._setGitError(e);
|
||
} finally {
|
||
this.promoting = false;
|
||
}
|
||
}
|
||
|
||
// ── Pull ─────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Pull changes from remote repository.
|
||
* @SIDE_EFFECT POSTs to /pull; opens merge dialog on GIT_UNFINISHED_MERGE.
|
||
*/
|
||
async handlePull(): Promise<void> {
|
||
this.clearGitError();
|
||
this.isPulling = true;
|
||
try {
|
||
await gitService.pull(this.dashboardId, this.resolvedEnvId);
|
||
addToast((this._t?.git as Record<string, unknown>)?.pull_success as string || 'Изменения получены из Git', 'success');
|
||
await this.loadWorkspace();
|
||
} catch (e: unknown) {
|
||
const handled = this.openUnfinishedMergeDialogFromError(e);
|
||
if (handled) {
|
||
await this.loadMergeRecoveryState();
|
||
} else {
|
||
this._setGitError(e);
|
||
}
|
||
} finally {
|
||
this.isPulling = false;
|
||
}
|
||
}
|
||
|
||
// ── Push ─────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Push local commits to remote repository.
|
||
* @SIDE_EFFECT POSTs to /push.
|
||
*/
|
||
async handlePush(): Promise<void> {
|
||
this.clearGitError();
|
||
this.isPushing = true;
|
||
try {
|
||
await gitService.push(this.dashboardId, this.resolvedEnvId);
|
||
addToast((this._t?.git as Record<string, unknown>)?.push_success as string || 'Изменения отправлены в Git', 'success');
|
||
await this.loadWorkspace();
|
||
} catch (e: unknown) {
|
||
this._setGitError(e);
|
||
} finally {
|
||
this.isPushing = false;
|
||
}
|
||
}
|
||
|
||
// ── Deploy Modal ─────────────────────────────────────────────
|
||
|
||
/**
|
||
* Open deployment target modal with PROD confirmation prompt.
|
||
*/
|
||
openDeployModal(): void {
|
||
if (this.currentEnvStage === 'PROD') {
|
||
const expected = String(this.dashboardId);
|
||
const confirmation = prompt(`Подтвердите деплой в PROD. Введите slug дашборда: ${expected}`);
|
||
if (String(confirmation || '').trim() !== expected) {
|
||
addToast('Подтверждение PROD не пройдено. Деплой отменен.', 'error');
|
||
return;
|
||
}
|
||
}
|
||
this.showDeployModal = true;
|
||
}
|
||
|
||
// ── Remote Repository ────────────────────────────────────────
|
||
|
||
/**
|
||
* Create a remote repository on the selected Git provider.
|
||
* @SIDE_EFFECT POSTs to /git/config/{id}/repositories; prompts user for repo name.
|
||
*/
|
||
async handleCreateRemoteRepo(): Promise<void> {
|
||
const config = resolveDefaultConfig(this.configs, this.selectedConfigId);
|
||
if (!config) {
|
||
addToast((this._t?.git as Record<string, unknown>)?.init_validation_error as string || 'Сначала выберите Git сервер', 'error');
|
||
return;
|
||
}
|
||
if (!this.selectedConfigId && config.id) this.selectedConfigId = String(config.id);
|
||
const suggestedName = buildSuggestedRepoName(this.dashboardTitle, this.dashboardId);
|
||
const inputName = prompt(`Repository name for ${config.provider}:`, suggestedName);
|
||
const repoName = String(inputName || '').trim();
|
||
if (!repoName) return;
|
||
this.clearGitError();
|
||
this.creatingRemoteRepo = true;
|
||
try {
|
||
const repo: CreateRepoResult = await gitService.createRemoteRepository(config.id, {
|
||
name: repoName,
|
||
private: true,
|
||
description: `Superset dashboard ${this.dashboardId}: ${this.dashboardTitle || repoName}`,
|
||
auto_init: true,
|
||
default_branch: 'main',
|
||
} satisfies CreateRepoPayload);
|
||
const url = repo?.clone_url || repo?.html_url || '';
|
||
if (!url) throw new Error('Remote repository created, but URL is empty');
|
||
this.remoteUrl = url;
|
||
addToast(`Repository created on ${config.provider}`, 'success');
|
||
} catch (e: unknown) {
|
||
if ((e as Record<string, unknown>)?.status === 409 && /already exists/i.test(String((e as Error)?.message || ''))) {
|
||
addToast((this._t?.git as Record<string, unknown>)?.repo_already_exists as string || 'Repository already exists. Enter its URL below and click Init.', 'warning', 0);
|
||
} else {
|
||
this._setGitError(e, 'warning');
|
||
}
|
||
} finally {
|
||
this.creatingRemoteRepo = false;
|
||
}
|
||
}
|
||
|
||
// ── Init Repository ──────────────────────────────────────────
|
||
|
||
/**
|
||
* Initialize local Git repository for the dashboard: clone remote.
|
||
* @SIDE_EFFECT POSTs to /git/repositories/{ref}/init.
|
||
*/
|
||
async handleInit(): Promise<void> {
|
||
if (!this.selectedConfigId || !this.remoteUrl) {
|
||
addToast((this._t?.git as Record<string, unknown>)?.init_validation_error as string || 'Заполните все поля', 'error');
|
||
return;
|
||
}
|
||
if (!this.resolvedEnvId && !isNumericDashboardRef(this.dashboardId)) {
|
||
addToast('Environment must be selected to initialize Git for this dashboard.', 'error');
|
||
return;
|
||
}
|
||
this.clearGitError();
|
||
this.loading = true;
|
||
try {
|
||
await gitService.initRepository(this.dashboardId, this.selectedConfigId, this.remoteUrl, this.resolvedEnvId);
|
||
addToast((this._t?.git as Record<string, unknown>)?.init_success as string || 'Репозиторий инициализирован', 'success');
|
||
const provider = resolveDefaultConfig(this.configs, this.selectedConfigId)?.provider || '';
|
||
this.initialized = true;
|
||
this.repositoryProvider = provider;
|
||
await this.loadWorkspace();
|
||
} catch (e: unknown) {
|
||
this._setGitError(e);
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
}
|
||
|
||
// ── Merge Recovery ───────────────────────────────────────────
|
||
|
||
/**
|
||
* Parse error for unfinished merge context and open dialog.
|
||
* @POST Returns true and opens dialog if merge context found.
|
||
*/
|
||
openUnfinishedMergeDialogFromError(error: unknown): boolean {
|
||
const context = extractUnfinishedMergeContext(error);
|
||
if (!context) return false;
|
||
this.unfinishedMergeContext = context;
|
||
this.showUnfinishedMergeDialog = true;
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Load merge recovery state from backend for unfinished merge.
|
||
* @SIDE_EFFECT GETs /merge/status from backend.
|
||
*/
|
||
async loadMergeRecoveryState(): Promise<void> {
|
||
this.clearGitError();
|
||
this.mergeRecoveryLoading = true;
|
||
try {
|
||
const status: MergeStatus = await gitService.getMergeStatus(this.dashboardId, this.resolvedEnvId);
|
||
if (!status?.has_unfinished_merge) {
|
||
this.closeUnfinishedMergeDialog();
|
||
return;
|
||
}
|
||
this.unfinishedMergeContext = {
|
||
...(this.unfinishedMergeContext || {}),
|
||
message: this.unfinishedMergeContext?.message || ((this._t?.git as Record<string, unknown>)?.unfinished_merge as Record<string, unknown>)?.default_message as string || '',
|
||
repositoryPath: String(status.repository_path || this.unfinishedMergeContext?.repositoryPath || ''),
|
||
gitDir: String(status.git_dir || this.unfinishedMergeContext?.gitDir || ''),
|
||
currentBranch: String(status.current_branch || this.unfinishedMergeContext?.currentBranch || ''),
|
||
mergeHead: String(status.merge_head || this.unfinishedMergeContext?.mergeHead || ''),
|
||
mergeMessagePreview: String(status.merge_message_preview || ''),
|
||
nextSteps: Array.isArray(this.unfinishedMergeContext?.nextSteps) ? this.unfinishedMergeContext.nextSteps : [],
|
||
commands: Array.isArray(this.unfinishedMergeContext?.commands) ? this.unfinishedMergeContext.commands : [],
|
||
conflictsCount: Number(status.conflicts_count || 0),
|
||
};
|
||
} catch (e: unknown) {
|
||
this._setGitError(e);
|
||
} finally {
|
||
this.mergeRecoveryLoading = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Close unfinished merge dialog and reset merge state.
|
||
*/
|
||
closeUnfinishedMergeDialog(): void {
|
||
this.showUnfinishedMergeDialog = false;
|
||
this.unfinishedMergeContext = null;
|
||
this.mergeConflicts = [];
|
||
this.showConflictResolver = false;
|
||
}
|
||
|
||
/**
|
||
* Load merge conflicts and open conflict resolver UI.
|
||
* @SIDE_EFFECT GETs /merge/conflicts from backend.
|
||
*/
|
||
async handleOpenConflictResolver(): Promise<void> {
|
||
this.clearGitError();
|
||
this.mergeRecoveryLoading = true;
|
||
try {
|
||
const mc: MergeConflict[] = await gitService.getMergeConflicts(this.dashboardId, this.resolvedEnvId);
|
||
if (!Array.isArray(mc) || mc.length === 0) {
|
||
addToast(((this._t?.git as Record<string, unknown>)?.unfinished_merge as Record<string, unknown>)?.no_conflicts as string || 'No unresolved conflicts were found', 'info');
|
||
return;
|
||
}
|
||
this.mergeConflicts = mc;
|
||
this.showConflictResolver = true;
|
||
} catch (e: unknown) {
|
||
this._setGitError(e);
|
||
} finally {
|
||
this.mergeRecoveryLoading = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Apply conflict resolutions and stage resolved files.
|
||
* @SIDE_EFFECT POSTs resolutions to /merge/resolve.
|
||
*/
|
||
async handleResolveConflicts(event: CustomEvent): Promise<void> {
|
||
const detail = event?.detail || {};
|
||
const resolutions: Array<{ file_path: string; resolution: unknown }> = Object.entries(detail).map(([fp, r]) => ({ file_path: fp, resolution: r }));
|
||
if (!resolutions.length) {
|
||
addToast(((this._t?.git as Record<string, unknown>)?.unfinished_merge as Record<string, unknown>)?.resolve_empty as string || 'No conflict resolutions selected', 'warning');
|
||
return;
|
||
}
|
||
this.clearGitError();
|
||
this.mergeResolveInProgress = true;
|
||
try {
|
||
await gitService.resolveMergeConflicts(this.dashboardId, resolutions, this.resolvedEnvId);
|
||
addToast(((this._t?.git as Record<string, unknown>)?.unfinished_merge as Record<string, unknown>)?.resolve_success as string || 'Conflicts were resolved and staged', 'success');
|
||
this.showConflictResolver = false;
|
||
await this.loadMergeRecoveryState();
|
||
await this.loadWorkspace();
|
||
} catch (e: unknown) {
|
||
this._setGitError(e);
|
||
} finally {
|
||
this.mergeResolveInProgress = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Abort current unfinished merge.
|
||
* @SIDE_EFFECT POSTs to /merge/abort.
|
||
*/
|
||
async handleAbortUnfinishedMerge(): Promise<void> {
|
||
this.clearGitError();
|
||
this.mergeAbortInProgress = true;
|
||
try {
|
||
await gitService.abortMerge(this.dashboardId, this.resolvedEnvId);
|
||
addToast(((this._t?.git as Record<string, unknown>)?.unfinished_merge as Record<string, unknown>)?.abort_success as string || 'Merge was aborted', 'success');
|
||
this.closeUnfinishedMergeDialog();
|
||
await this.loadWorkspace();
|
||
} catch (e: unknown) {
|
||
this._setGitError(e);
|
||
} finally {
|
||
this.mergeAbortInProgress = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Finalize unfinished merge by creating merge commit.
|
||
* @SIDE_EFFECT POSTs to /merge/continue.
|
||
*/
|
||
async handleContinueUnfinishedMerge(): Promise<void> {
|
||
this.clearGitError();
|
||
this.mergeContinueInProgress = true;
|
||
try {
|
||
await gitService.continueMerge(this.dashboardId, '', this.resolvedEnvId);
|
||
addToast(((this._t?.git as Record<string, unknown>)?.unfinished_merge as Record<string, unknown>)?.continue_success as string || 'Merge commit created successfully', 'success');
|
||
this.closeUnfinishedMergeDialog();
|
||
await this.loadWorkspace();
|
||
} catch (e: unknown) {
|
||
this._setGitError(e);
|
||
await this.loadMergeRecoveryState();
|
||
} finally {
|
||
this.mergeContinueInProgress = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get shell commands text for manual merge recovery.
|
||
*/
|
||
getUnfinishedMergeCommandsText(): string {
|
||
if (!this.unfinishedMergeContext?.commands?.length) return '';
|
||
return this.unfinishedMergeContext.commands.join('\n');
|
||
}
|
||
|
||
/**
|
||
* Copy merge recovery commands to clipboard.
|
||
*/
|
||
async handleCopyUnfinishedMergeCommands(): Promise<void> {
|
||
const text = this.getUnfinishedMergeCommandsText();
|
||
if (!text) {
|
||
addToast(((this._t?.git as Record<string, unknown>)?.unfinished_merge as Record<string, unknown>)?.copy_empty as string || 'Команды для копирования отсутствуют', 'warning');
|
||
return;
|
||
}
|
||
this.copyingUnfinishedMergeCommands = true;
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
addToast(((this._t?.git as Record<string, unknown>)?.unfinished_merge as Record<string, unknown>)?.copy_success as string || 'Команды скопированы в буфер обмена', 'success');
|
||
} catch {
|
||
addToast(((this._t?.git as Record<string, unknown>)?.unfinished_merge as Record<string, unknown>)?.copy_failed as string || 'Не удалось скопировать команды', 'error');
|
||
} finally {
|
||
this.copyingUnfinishedMergeCommands = false;
|
||
}
|
||
}
|
||
}
|
||
// #endregion Git.ManagerModel
|