+
diff --git a/frontend/src/routes/validation/[id]/+page.svelte b/frontend/src/routes/validation/[id]/+page.svelte
index 89594b5a..248c9fb1 100644
--- a/frontend/src/routes/validation/[id]/+page.svelte
+++ b/frontend/src/routes/validation/[id]/+page.svelte
@@ -111,6 +111,15 @@
try {
const envs = await api.getEnvironments?.() || [];
environments = Array.isArray(envs) ? envs : [];
+ // Pre-fill with active environment for new tasks
+ if (!environmentId) {
+ const { get } = await import('svelte/store');
+ const envCtx = await import('$lib/stores/environmentContext.js');
+ const state = get(envCtx.environmentContextStore);
+ if (state.selectedEnvId && environments.some((e) => e.id === state.selectedEnvId)) {
+ environmentId = state.selectedEnvId;
+ }
+ }
} catch {
environments = [];
}
diff --git a/frontend/src/services/__tests__/gitService.test.js b/frontend/src/services/__tests__/gitService.test.js
index c81dc5cb..18c7ffe7 100644
--- a/frontend/src/services/__tests__/gitService.test.js
+++ b/frontend/src/services/__tests__/gitService.test.js
@@ -27,7 +27,7 @@ describe('gitService', () => {
it('getConfigs calls /git/config', async () => {
requestApi.mockResolvedValue([]);
const result = await gitService.getConfigs();
- expect(requestApi).toHaveBeenCalledWith('/git/config');
+ expect(requestApi).toHaveBeenCalledWith('/git/config', 'GET', null, { suppressToast: true });
expect(result).toEqual([]);
});
@@ -37,7 +37,7 @@ describe('gitService', () => {
const config = { name: 'Test', provider: 'GITHUB' };
requestApi.mockResolvedValue({ id: '1', ...config });
const result = await gitService.createConfig(config);
- expect(requestApi).toHaveBeenCalledWith('/git/config', 'POST', config);
+ expect(requestApi).toHaveBeenCalledWith('/git/config', 'POST', config, { suppressToast: true });
expect(result.id).toBe('1');
});
@@ -46,7 +46,7 @@ describe('gitService', () => {
it('deleteConfig calls DELETE /git/config/:id', async () => {
requestApi.mockResolvedValue({ status: 'success' });
await gitService.deleteConfig('config-123');
- expect(requestApi).toHaveBeenCalledWith('/git/config/config-123', 'DELETE');
+ expect(requestApi).toHaveBeenCalledWith('/git/config/config-123', 'DELETE', null, { suppressToast: true });
});
// @PRE configId must exist
@@ -55,7 +55,7 @@ describe('gitService', () => {
const config = { name: 'Updated' };
requestApi.mockResolvedValue({ id: 'config-123', ...config });
await gitService.updateConfig('config-123', config);
- expect(requestApi).toHaveBeenCalledWith('/git/config/config-123', 'PUT', config);
+ expect(requestApi).toHaveBeenCalledWith('/git/config/config-123', 'PUT', config, { suppressToast: true });
});
// @PRE Config must contain valid URL and PAT
@@ -64,7 +64,7 @@ describe('gitService', () => {
const config = { url: 'https://github.com', pat: 'secret' };
requestApi.mockResolvedValue({ status: 'success' });
await gitService.testConnection(config);
- expect(requestApi).toHaveBeenCalledWith('/git/config/test', 'POST', config);
+ expect(requestApi).toHaveBeenCalledWith('/git/config/test', 'POST', config, { suppressToast: true });
});
});
@@ -74,7 +74,7 @@ describe('gitService', () => {
it('listGiteaRepositories calls GET /git/config/:id/gitea/repos', async () => {
requestApi.mockResolvedValue([]);
await gitService.listGiteaRepositories('conf-1');
- expect(requestApi).toHaveBeenCalledWith('/git/config/conf-1/gitea/repos');
+ expect(requestApi).toHaveBeenCalledWith('/git/config/conf-1/gitea/repos', 'GET', null, { suppressToast: true });
});
// @PRE configId references GITEA
@@ -83,7 +83,7 @@ describe('gitService', () => {
const payload = { name: 'new-repo' };
requestApi.mockResolvedValue({ name: 'new-repo' });
await gitService.createGiteaRepository('conf-1', payload);
- expect(requestApi).toHaveBeenCalledWith('/git/config/conf-1/gitea/repos', 'POST', payload);
+ expect(requestApi).toHaveBeenCalledWith('/git/config/conf-1/gitea/repos', 'POST', payload, { suppressToast: true });
});
// @PRE configId references supported provider
@@ -92,7 +92,7 @@ describe('gitService', () => {
const payload = { name: 'new-remote' };
requestApi.mockResolvedValue({ name: 'new-remote' });
await gitService.createRemoteRepository('conf-1', payload);
- expect(requestApi).toHaveBeenCalledWith('/git/config/conf-1/repositories', 'POST', payload);
+ expect(requestApi).toHaveBeenCalledWith('/git/config/conf-1/repositories', 'POST', payload, { suppressToast: true });
});
// @PRE configId references GITEA
@@ -100,7 +100,7 @@ describe('gitService', () => {
it('deleteGiteaRepository calls DELETE with encoded owner and repo', async () => {
requestApi.mockResolvedValue({ status: 'success' });
await gitService.deleteGiteaRepository('conf-1', 'my-org', 'my-repo');
- expect(requestApi).toHaveBeenCalledWith('/git/config/conf-1/gitea/repos/my-org/my-repo', 'DELETE');
+ expect(requestApi).toHaveBeenCalledWith('/git/config/conf-1/gitea/repos/my-org/my-repo', 'DELETE', null, { suppressToast: true });
});
});
@@ -112,7 +112,7 @@ describe('gitService', () => {
await gitService.initRepository(123, 'conf-1', 'http://remote.git');
expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/init', 'POST', {
config_id: 'conf-1', remote_url: 'http://remote.git'
- });
+ }, { suppressToast: true });
});
// @PRE Repo initialized
@@ -120,7 +120,7 @@ describe('gitService', () => {
it('getRepositoryBinding calls GET', async () => {
requestApi.mockResolvedValue({});
await gitService.getRepositoryBinding('dash-slug');
- expect(requestApi).toHaveBeenCalledWith('/git/repositories/dash-slug');
+ expect(requestApi).toHaveBeenCalledWith('/git/repositories/dash-slug', 'GET', null, { suppressToast: true });
});
// @PRE Repo initialized
@@ -128,7 +128,7 @@ describe('gitService', () => {
it('getBranches calls GET /branches', async () => {
requestApi.mockResolvedValue([]);
await gitService.getBranches(123);
- expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/branches');
+ expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/branches', 'GET', null, { suppressToast: true });
});
// @PRE Source branch exists
@@ -138,7 +138,7 @@ describe('gitService', () => {
await gitService.createBranch(123, 'feature', 'main');
expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/branches', 'POST', {
name: 'feature', from_branch: 'main'
- });
+ }, { suppressToast: true });
});
// @PRE Target branch exists
@@ -146,7 +146,7 @@ describe('gitService', () => {
it('checkoutBranch calls POST /checkout', async () => {
requestApi.mockResolvedValue({});
await gitService.checkoutBranch(123, 'feature');
- expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/checkout', 'POST', { name: 'feature' });
+ expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/checkout', 'POST', { name: 'feature' }, { suppressToast: true });
});
// @PRE Message not empty
@@ -156,7 +156,7 @@ describe('gitService', () => {
await gitService.commit(123, 'Initial commit', ['file.txt']);
expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/commit', 'POST', {
message: 'Initial commit', files: ['file.txt']
- });
+ }, { suppressToast: true });
});
// @PRE Remote configured
@@ -164,7 +164,7 @@ describe('gitService', () => {
it('push calls POST /push', async () => {
requestApi.mockResolvedValue({});
await gitService.push(123);
- expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/push', 'POST');
+ expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/push', 'POST', null, { suppressToast: true });
});
// @PRE Dashboard ref valid
@@ -172,7 +172,7 @@ describe('gitService', () => {
it('deleteRepository calls DELETE', async () => {
requestApi.mockResolvedValue({});
await gitService.deleteRepository(123);
- expect(requestApi).toHaveBeenCalledWith('/git/repositories/123', 'DELETE');
+ expect(requestApi).toHaveBeenCalledWith('/git/repositories/123', 'DELETE', null, { suppressToast: true });
});
// @PRE Remote configured
@@ -180,7 +180,7 @@ describe('gitService', () => {
it('pull calls POST /pull', async () => {
requestApi.mockResolvedValue({});
await gitService.pull(123);
- expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/pull', 'POST');
+ expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/pull', 'POST', null, { suppressToast: true });
});
});
@@ -189,7 +189,7 @@ describe('gitService', () => {
it('getEnvironments calls GET /environments', async () => {
requestApi.mockResolvedValue([]);
await gitService.getEnvironments();
- expect(requestApi).toHaveBeenCalledWith('/git/environments');
+ expect(requestApi).toHaveBeenCalledWith('/git/environments', 'GET', null, { suppressToast: true });
});
// @PRE Environment active
@@ -197,21 +197,21 @@ describe('gitService', () => {
it('deploy calls POST /deploy with environment_id', async () => {
requestApi.mockResolvedValue({});
await gitService.deploy(123, 'env-1');
- expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/deploy', 'POST', { environment_id: 'env-1' });
+ expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/deploy', 'POST', { environment_id: 'env-1' }, { suppressToast: true });
});
// @POST Returns commit history
it('getHistory calls GET /history with limit', async () => {
requestApi.mockResolvedValue([]);
await gitService.getHistory(123, 10);
- expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/history?limit=10');
+ expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/history?limit=10', 'GET', null, { suppressToast: true });
});
// @POST Syncs local and remote
it('sync calls POST /sync with optional params', async () => {
requestApi.mockResolvedValue({});
await gitService.sync(123, 'env-source', 'env-target');
- expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/sync?source_env_id=env-source&env_id=env-target', 'POST');
+ expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/sync?source_env_id=env-source&env_id=env-target', 'POST', null, { suppressToast: true });
});
// @PRE Dashboard id valid
@@ -219,7 +219,7 @@ describe('gitService', () => {
it('getStatus calls GET /status', async () => {
requestApi.mockResolvedValue({});
await gitService.getStatus(123);
- expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/status');
+ expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/status', 'GET', null, { suppressToast: true });
});
// @PRE Dashboard ids array
@@ -227,7 +227,7 @@ describe('gitService', () => {
it('getStatusesBatch calls POST /status/batch', async () => {
requestApi.mockResolvedValue({});
await gitService.getStatusesBatch([1, 2, 3]);
- expect(requestApi).toHaveBeenCalledWith('/git/repositories/status/batch', 'POST', { dashboard_ids: [1, 2, 3] });
+ expect(requestApi).toHaveBeenCalledWith('/git/repositories/status/batch', 'POST', { dashboard_ids: [1, 2, 3] }, { suppressToast: true });
});
// @PRE Dashboard id valid
@@ -235,7 +235,7 @@ describe('gitService', () => {
it('getDiff calls GET /diff with params', async () => {
requestApi.mockResolvedValue('');
await gitService.getDiff(123, 'file.txt', true, 'env-1');
- expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/diff?file_path=file.txt&staged=true&env_id=env-1');
+ expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/diff?file_path=file.txt&staged=true&env_id=env-1', 'GET', null, { suppressToast: true });
});
// @PRE Repo initialized
@@ -244,7 +244,7 @@ describe('gitService', () => {
const payload = { from_branch: 'dev', to_branch: 'main', mode: 'mr' };
requestApi.mockResolvedValue({});
await gitService.promote(123, payload);
- expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/promote', 'POST', payload);
+ expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/promote', 'POST', payload, { suppressToast: true });
});
});
});
diff --git a/frontend/src/services/gitService.js b/frontend/src/services/gitService.js
index 5b52c30d..fc02d9bd 100644
--- a/frontend/src/services/gitService.js
+++ b/frontend/src/services/gitService.js
@@ -21,6 +21,17 @@ function buildDashboardRepoEndpoint(dashboardRef, suffix, envId = null) {
}
// #endregion buildDashboardRepoEndpoint
+// #region gitRequest [C:2] [TYPE Function]
+// @BRIEF Wrapper around requestApi that auto-suppresses API-level toasts —
+// Git error display is handled by useGitManager.js handlers (persistent toasts + modal banner).
+// @PRE Works like requestApi(endpoint, method, body, extraOptions).
+// @POST Api-level toast suppressed; error propagates to caller for handler-level display.
+async function gitRequest(endpoint, method = 'GET', body = null, extraOptions = {}) {
+ const requestOptions = { ...extraOptions, suppressToast: true };
+ return requestApi(endpoint, method, body, requestOptions);
+}
+// #endregion gitRequest
+
// #region gitService [C:3] [TYPE Object]
// @BRIEF Exported Git API client with methods for all repository and config operations.
// @RELATION CALLS -> [ApiModule.requestApi]
@@ -33,7 +44,7 @@ export const gitService = {
// @DATA_CONTRACT None -> Array
async getConfigs() {
console.log('[getConfigs][Action] Fetching Git configs');
- return requestApi(`${API_BASE}/config`);
+ return gitRequest(`${API_BASE}/config`);
},
// #endregion getConfigs
@@ -44,7 +55,7 @@ export const gitService = {
// @DATA_CONTRACT Input(GitServerConfig) -> Output(GitServerConfig)
async createConfig(config) {
console.log('[createConfig][Action] Creating Git config');
- return requestApi(`${API_BASE}/config`, 'POST', config);
+ return gitRequest(`${API_BASE}/config`, 'POST', config);
},
// #endregion createConfig
@@ -54,7 +65,7 @@ export const gitService = {
// @POST Config is removed from the backend.
async deleteConfig(configId) {
console.log(`[deleteConfig][Action] Deleting Git config ${configId}`);
- return requestApi(`${API_BASE}/config/${configId}`, 'DELETE');
+ return gitRequest(`${API_BASE}/config/${configId}`, 'DELETE');
},
// #endregion deleteConfig
@@ -64,7 +75,7 @@ export const gitService = {
// @POST Config is updated and returned.
async updateConfig(configId, config) {
console.log(`[updateConfig][Action] Updating Git config ${configId}`);
- return requestApi(`${API_BASE}/config/${configId}`, 'PUT', config);
+ return gitRequest(`${API_BASE}/config/${configId}`, 'PUT', config);
},
// #endregion updateConfig
@@ -75,7 +86,7 @@ export const gitService = {
// @DATA_CONTRACT Input(GitServerConfig) -> Output({status: string})
async testConnection(config) {
console.log('[testConnection][Action] Testing Git connection');
- return requestApi(`${API_BASE}/config/test`, 'POST', config);
+ return gitRequest(`${API_BASE}/config/test`, 'POST', config);
},
// #endregion testConnection
@@ -85,7 +96,7 @@ export const gitService = {
// @POST Returns array of Gitea repository metadata.
async listGiteaRepositories(configId) {
console.log(`[listGiteaRepositories][Action] Listing Gitea repositories for config ${configId}`);
- return requestApi(`${API_BASE}/config/${configId}/gitea/repos`);
+ return gitRequest(`${API_BASE}/config/${configId}/gitea/repos`);
},
// #endregion listGiteaRepositories
@@ -95,7 +106,7 @@ export const gitService = {
// @POST Repository created on Gitea; returns created repo metadata.
async createGiteaRepository(configId, payload) {
console.log(`[createGiteaRepository][Action] Creating Gitea repository ${payload?.name} for config ${configId}`);
- return requestApi(`${API_BASE}/config/${configId}/gitea/repos`, 'POST', payload);
+ return gitRequest(`${API_BASE}/config/${configId}/gitea/repos`, 'POST', payload);
},
// #endregion createGiteaRepository
@@ -106,7 +117,7 @@ export const gitService = {
// @DATA_CONTRACT Input(configId, RemoteRepoCreatePayload) -> Output(RemoteRepoSchema)
async createRemoteRepository(configId, payload) {
console.log(`[createRemoteRepository][Action] Creating remote repository ${payload?.name} for config ${configId}`);
- return requestApi(`${API_BASE}/config/${configId}/repositories`, 'POST', payload);
+ return gitRequest(`${API_BASE}/config/${configId}/repositories`, 'POST', payload);
},
// #endregion createRemoteRepository
@@ -116,7 +127,7 @@ export const gitService = {
// @POST Repository deleted on Gitea server.
async deleteGiteaRepository(configId, owner, repoName) {
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 gitRequest(`${API_BASE}/config/${configId}/gitea/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}`, 'DELETE');
},
// #endregion deleteGiteaRepository
@@ -128,7 +139,7 @@ export const gitService = {
// @RELATION CALLS -> [ApiModule.requestApi]
async initRepository(dashboardRef, configId, remoteUrl, envId = null) {
console.log(`[initRepository][Action] Initializing repo for dashboard ${dashboardRef}`);
- return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/init', envId), 'POST', {
+ return gitRequest(buildDashboardRepoEndpoint(dashboardRef, '/init', envId), 'POST', {
config_id: configId,
remote_url: remoteUrl
});
@@ -141,7 +152,7 @@ export const gitService = {
// @POST Returns provider, config_id, remote_url, local_path.
async getRepositoryBinding(dashboardRef, envId = null) {
console.log(`[getRepositoryBinding][Action] Fetching repository binding for dashboard ${dashboardRef}`);
- return requestApi(buildDashboardRepoEndpoint(dashboardRef, '', envId));
+ return gitRequest(buildDashboardRepoEndpoint(dashboardRef, '', envId));
},
// #endregion getRepositoryBinding
@@ -151,7 +162,7 @@ export const gitService = {
// @POST Returns array of BranchSchema objects.
async getBranches(dashboardRef, envId = null) {
console.log(`[getBranches][Action] Fetching branches for dashboard ${dashboardRef}`);
- return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/branches', envId));
+ return gitRequest(buildDashboardRepoEndpoint(dashboardRef, '/branches', envId));
},
// #endregion getBranches
@@ -161,7 +172,7 @@ export const gitService = {
// @POST New branch is created on the backend.
async createBranch(dashboardRef, name, fromBranch, envId = null) {
console.log(`[createBranch][Action] Creating branch ${name} for dashboard ${dashboardRef}`);
- return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/branches', envId), 'POST', {
+ return gitRequest(buildDashboardRepoEndpoint(dashboardRef, '/branches', envId), 'POST', {
name,
from_branch: fromBranch
});
@@ -174,7 +185,7 @@ export const gitService = {
// @POST Repository HEAD is moved to the target branch.
async checkoutBranch(dashboardRef, name, envId = null) {
console.log(`[checkoutBranch][Action] Checking out branch ${name} for dashboard ${dashboardRef}`);
- return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/checkout', envId), 'POST', { name });
+ return gitRequest(buildDashboardRepoEndpoint(dashboardRef, '/checkout', envId), 'POST', { name });
},
// #endregion checkoutBranch
@@ -184,7 +195,7 @@ export const gitService = {
// @POST Changes are committed to the current branch.
async commit(dashboardRef, message, files, envId = null) {
console.log(`[commit][Action] Committing changes for dashboard ${dashboardRef}`);
- return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/commit', envId), 'POST', { message, files });
+ return gitRequest(buildDashboardRepoEndpoint(dashboardRef, '/commit', envId), 'POST', { message, files });
},
// #endregion commit
@@ -194,7 +205,7 @@ export const gitService = {
// @POST Remote is updated with local commits.
async push(dashboardRef, envId = null) {
console.log(`[push][Action] Pushing changes for dashboard ${dashboardRef}`);
- return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/push', envId), 'POST');
+ return gitRequest(buildDashboardRepoEndpoint(dashboardRef, '/push', envId), 'POST');
},
// #endregion push
@@ -204,7 +215,7 @@ export const gitService = {
// @POST Repository record and local folder are removed.
async deleteRepository(dashboardRef, envId = null) {
console.log(`[deleteRepository][Action] Deleting repository for dashboard ${dashboardRef}`);
- return requestApi(buildDashboardRepoEndpoint(dashboardRef, '', envId), 'DELETE');
+ return gitRequest(buildDashboardRepoEndpoint(dashboardRef, '', envId), 'DELETE');
},
// #endregion deleteRepository
@@ -214,7 +225,7 @@ export const gitService = {
// @POST Local repository is updated with remote changes.
async pull(dashboardRef, envId = null) {
console.log(`[pull][Action] Pulling changes for dashboard ${dashboardRef}`);
- return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/pull', envId), 'POST');
+ return gitRequest(buildDashboardRepoEndpoint(dashboardRef, '/pull', envId), 'POST');
},
// #endregion pull
@@ -224,7 +235,7 @@ export const gitService = {
// @POST Returns merge status payload with has_unfinished_merge flag.
async getMergeStatus(dashboardRef, envId = null) {
console.log(`[getMergeStatus][Action] Fetching merge status for dashboard ${dashboardRef}`);
- return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/status', envId));
+ return gitRequest(buildDashboardRepoEndpoint(dashboardRef, '/merge/status', envId));
},
// #endregion getMergeStatus
@@ -234,7 +245,7 @@ export const gitService = {
// @POST Returns array of conflict file entries with mine/theirs previews.
async getMergeConflicts(dashboardRef, envId = null) {
console.log(`[getMergeConflicts][Action] Fetching merge conflicts for dashboard ${dashboardRef}`);
- return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/conflicts', envId));
+ return gitRequest(buildDashboardRepoEndpoint(dashboardRef, '/merge/conflicts', envId));
},
// #endregion getMergeConflicts
@@ -244,7 +255,7 @@ export const gitService = {
// @POST Conflicts are resolved and staged.
async resolveMergeConflicts(dashboardRef, resolutions, envId = null) {
console.log(`[resolveMergeConflicts][Action] Resolving ${Array.isArray(resolutions) ? resolutions.length : 0} conflicts for dashboard ${dashboardRef}`);
- return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/resolve', envId), 'POST', {
+ return gitRequest(buildDashboardRepoEndpoint(dashboardRef, '/merge/resolve', envId), 'POST', {
resolutions: Array.isArray(resolutions) ? resolutions : []
});
},
@@ -256,7 +267,7 @@ export const gitService = {
// @POST Merge state is aborted or reported as absent.
async abortMerge(dashboardRef, envId = null) {
console.log(`[abortMerge][Action] Aborting merge for dashboard ${dashboardRef}`);
- return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/abort', envId), 'POST');
+ return gitRequest(buildDashboardRepoEndpoint(dashboardRef, '/merge/abort', envId), 'POST');
},
// #endregion abortMerge
@@ -266,7 +277,7 @@ export const gitService = {
// @POST Merge commit is created.
async continueMerge(dashboardRef, message = '', envId = null) {
console.log(`[continueMerge][Action] Continuing merge for dashboard ${dashboardRef}`);
- return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/continue', envId), 'POST', {
+ return gitRequest(buildDashboardRepoEndpoint(dashboardRef, '/merge/continue', envId), 'POST', {
message: String(message || '').trim() || null
});
},
@@ -277,7 +288,7 @@ export const gitService = {
// @POST Returns list of environment objects.
async getEnvironments() {
console.log('[getEnvironments][Action] Fetching environments');
- return requestApi(`${API_BASE}/environments`);
+ return gitRequest(`${API_BASE}/environments`);
},
// #endregion getEnvironments
@@ -287,7 +298,7 @@ export const gitService = {
// @POST Dashboard is imported into target Superset instance.
async deploy(dashboardRef, environmentId, envId = null) {
console.log(`[deploy][Action] Deploying dashboard ${dashboardRef} to environment ${environmentId}`);
- return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/deploy', envId), 'POST', {
+ return gitRequest(buildDashboardRepoEndpoint(dashboardRef, '/deploy', envId), 'POST', {
environment_id: environmentId
});
},
@@ -298,7 +309,7 @@ export const gitService = {
// @POST Returns list of commits with author, date, message.
async getHistory(dashboardRef, limit = 50, envId = null) {
console.log(`[getHistory][Action] Fetching history for dashboard ${dashboardRef}`);
- return requestApi(buildDashboardRepoEndpoint(dashboardRef, `/history?limit=${limit}`, envId));
+ return gitRequest(buildDashboardRepoEndpoint(dashboardRef, `/history?limit=${limit}`, envId));
},
// #endregion getHistory
@@ -312,7 +323,7 @@ export const gitService = {
if (envId) params.append('env_id', String(envId));
const query = params.toString();
const endpoint = `${API_BASE}/repositories/${encodeURIComponent(String(dashboardRef))}/sync${query ? `?${query}` : ''}`;
- return requestApi(endpoint, 'POST');
+ return gitRequest(endpoint, 'POST');
},
// #endregion sync
@@ -322,7 +333,7 @@ export const gitService = {
// @POST Returns status payload with is_dirty, staged_files, modified_files, current_branch.
async getStatus(dashboardRef, envId = null) {
console.log(`[getStatus][Action] Fetching status for dashboard ${dashboardRef}`);
- return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/status', envId));
+ return gitRequest(buildDashboardRepoEndpoint(dashboardRef, '/status', envId));
},
// #endregion getStatus
@@ -332,7 +343,7 @@ export const gitService = {
// @POST Returns a map of dashboard_id -> status payload (with NO_REPO for uninitialized).
async getStatusesBatch(dashboardIds) {
console.log(`[getStatusesBatch][Action] Fetching statuses for ${dashboardIds.length} dashboards`);
- return requestApi(`${API_BASE}/repositories/status/batch`, 'POST', {
+ return gitRequest(`${API_BASE}/repositories/status/batch`, 'POST', {
dashboard_ids: dashboardIds
});
},
@@ -350,7 +361,7 @@ export const gitService = {
if (staged) params.append('staged', 'true');
if (envId) params.append('env_id', String(envId));
if (params.toString()) endpoint += `?${params.toString()}`;
- return requestApi(endpoint);
+ return gitRequest(endpoint);
},
// #endregion getDiff
@@ -360,7 +371,7 @@ export const gitService = {
// @POST Returns MR URL (MR mode) or merge status (direct mode).
async promote(dashboardRef, payload, envId = null) {
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 gitRequest(buildDashboardRepoEndpoint(dashboardRef, '/promote', envId), 'POST', payload);
}
// #endregion promote
};
diff --git a/specs/011-git-integration-dashboard/plan.md b/specs/011-git-integration-dashboard/plan.md
index c0f6dd57..abc34540 100644
--- a/specs/011-git-integration-dashboard/plan.md
+++ b/specs/011-git-integration-dashboard/plan.md
@@ -1,12 +1,43 @@
# Implementation Plan: Git Integration Plugin for Dashboard Development
-**Branch**: `011-git-integration-dashboard` | **Date**: 2026-01-22 | **Spec**: [spec.md](specs/011-git-integration-dashboard/spec.md)
+**Branch**: `011-git-integration-dashboard` | **Date**: 2026-01-22 | **Last Updated**: 2026-05-27 | **Spec**: [spec.md](specs/011-git-integration-dashboard/spec.md)
**Input**: Feature specification from `/specs/011-git-integration-dashboard/spec.md`
## Summary
Implement a Git integration plugin that allows dashboard developers to version control their Superset dashboards. The plugin will support GitLab, GitHub, and Gitea, enabling branch management, committing/pushing changes (as unpacked Superset exports), and deploying to target environments via the Superset API.
+## Branch Model
+
+### Current Implementation (2026-05-27)
+```
+dev → preprod → prod
+```
+
+**Environment branches**:
+- `dev` — разработка (соответствует DEV stage)
+- `preprod` — предварительное тестирование (соответствует PREPROD stage)
+- `prod` — продакшен (соответствует PROD stage)
+
+**Workflow**:
+1. Разработка ведётся в ветке `dev`
+2. После тестирования изменения продвигаются в `preprod` через MR
+3. После approval изменения мержатся в `prod` через MR
+4. Deploy в Superset происходит только после merge в `prod`
+
+### Future Enhancement (Planned)
+```
+feature/auth ─┐
+feature/charts ─┼─→ dev → preprod → prod
+hotfix/bug-123 ─┘
+```
+
+**Feature branches**:
+- `feature/*` — создаются от `dev`, мержатся обратно в `dev`
+- `hotfix/*` — создаются от `prod`, мержатся в `prod` и `dev`
+
+**См. Phase 13 в tasks.md для деталей реализации**
+
## Technical Context
**Language/Version**: Python 3.9+ (Backend), Node.js 18+ (Frontend)
@@ -98,3 +129,61 @@ frontend/
1. **Environment Management**: CRUD for `Environment` (Target Superset instances).
2. **Deployment Logic**: Implement deployment via Superset Import API (POST /api/v1/dashboard/import/).
* Handle zip packing from Git repo state before import.
+
+## Future Enhancements
+
+### Feature & Hotfix Branch Support (Phase 13)
+
+**Цель**: Расширить модель веток для поддержки параллельной разработки нескольких фич и срочных исправлений.
+
+**Приоритет**: P2 (после стабилизации базового workflow)
+
+**Сложность**: ~25-30 часов
+
+#### Backend Implementation
+1. **`merge_branch`** — новый метод для слияния веток
+ - Merge source_branch в target_branch
+ - Обработка конфликтов (abort + показать ошибку)
+ - Возврат статуса: success / conflicts
+
+2. **`delete_branch`** — удаление веток после merge
+ - Запрет удаления environment branches (dev/preprod/prod)
+ - Автоматическое удаление после успешного merge (опционально)
+
+3. **Расширение `list_branches`**
+ - Группировка по типам: Environment / Feature / Hotfix
+ - Фильтрация по префиксу (feature/*, hotfix/*)
+
+4. **Branch protection rules** (опционально)
+ - Запрет прямого push в prod без MR
+ - Требование code review для environment branches
+
+#### Frontend Implementation
+1. **BranchSelector UI** — группированный dropdown
+ - Environment branches (dev, preprod, prod)
+ - Feature branches (feature/*)
+ - Hotfix branches (hotfix/*)
+
+2. **Create branch dialog** — расширенный UI
+ - Выбор типа: feature / hotfix / bugfix
+ - Валидация имени (regex: `^[a-zA-Z0-9._\/-]+$`)
+ - Выбор source branch
+
+3. **Merge UI** — кнопка merge для feature branches
+ - Показ diff перед merge
+ - Обработка конфликтов через ConflictResolver
+ - Опция auto-delete после merge
+
+4. **ReleasePanel** — поддержка merge feature → dev
+ - Расширенный список source branches
+ - Визуализация merge path
+
+#### Риски и митигации
+- **Risk**: Merge conflicts в YAML файлах могут быть сложными
+ - **Mitigation**: Использовать существующий ConflictResolver, чёткие сообщения об ошибках
+- **Risk**: Branch protection может замедлить workflow для маленьких команд
+ - **Mitigation**: Сделать protection rules опциональными через GitServerConfig
+- **Risk**: Auto-delete может привести к потере работы
+ - **Mitigation**: Требовать явное подтверждение, добавить "undo" на 5 минут
+
+**См. tasks.md Phase 13 для детального списка задач**
diff --git a/specs/011-git-integration-dashboard/spec.md b/specs/011-git-integration-dashboard/spec.md
index fd84ec0d..6406882b 100644
--- a/specs/011-git-integration-dashboard/spec.md
+++ b/specs/011-git-integration-dashboard/spec.md
@@ -2,9 +2,36 @@
**Feature Branch**: `011-git-integration-dashboard`
**Created**: 2026-01-18
-**Status**: In Progress
+**Status**: In Progress
+**Last Updated**: 2026-05-27
**Input**: User description: "Нужен плагин интеграции git в разработку дашбордов. Требования - возможность настройки целевого git сервера (базово - интеграция с gitlab), хранение и синхронизация веток разработки дашбордов, возможность публикацию в другое целевое окружение после коммита"
+## Completed Changes
+
+### Branch Naming Convention Update (2026-05-27)
+
+**Изменение**: Переименование ветки `main` → `prod` для соответствия environment stages.
+
+**Причина**:
+- Устранение путаницы между Git ветками и environment stages
+- Единая терминология: `dev` → `preprod` → `prod`
+- Упрощение ментальной модели для пользователей
+
+**Что было сделано**:
+- Backend: `_ensure_gitflow_branches` теперь создаёт `prod`, `dev`, `preprod`
+- Backend: `create_branch` default parameter изменён на `prod`
+- Backend: Remote providers (GitHub, GitLab, Gitea) используют `prod` как default_branch
+- Frontend: `BranchSelector` default currentBranch = `prod`
+- Frontend: `GitManager` initial state = `prod`
+- Frontend: `GitReleasePanel` UI label: `PROD (prod)`
+- Frontend: `git-utils.js` promote defaults: `preprod → prod`
+- Tests: Обновлены все тесты с `main` на `prod`
+
+**Backward Compatibility**:
+- Существующие репозитории с веткой `main` продолжают работать
+- Новые репозитории создаются с веткой `prod`
+- Миграция не требуется (пользователь подтвердил)
+
## User Scenarios & Testing *(mandatory)*