Add Gitea integration + ESLint setup
Phase 3: Gitea Integration - Migration 012: app_settings table + gitea_commit_sha on endpoint_versions - SettingsService: encrypted token storage (AES-256-GCM from JWT_SECRET) - GiteaService: full REST API client — repo management, file CRUD, branches, commit history, diff/compare, pull requests, sync all - giteaController + routes: 15 API endpoints for settings, branches, commits, PRs, endpoint info, sync - VersionService hooks: auto-sync to Gitea on publish/draft (non-blocking) - Frontend: Gitea tab in Settings (connection, sync status, branch mgmt), Gitea panel in EndpointEditor (file link, last commit SHA) - docker-compose.gitea.yml: optional companion Gitea container - Cache fix: index.html served with no-cache for instant deploys ESLint Setup - Backend: eslint 8 + @typescript-eslint configured - Frontend: eslint 8 + @typescript-eslint + react-hooks + react-refresh - Fixed 15 lint issues: unused imports, require statements, escapes, Function type, empty catch blocks - Added npm run lint / lint:fix scripts Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
21
backend/.eslintrc.cjs
Normal file
21
backend/.eslintrc.cjs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: { node: true, es2020: true },
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
],
|
||||||
|
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||||
|
'no-console': 'off',
|
||||||
|
'prefer-const': 'warn',
|
||||||
|
'@typescript-eslint/no-require-imports': 'off',
|
||||||
|
},
|
||||||
|
};
|
||||||
1495
backend/package-lock.json
generated
1495
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,9 @@
|
|||||||
"@types/swagger-jsdoc": "^6.0.4",
|
"@types/swagger-jsdoc": "^6.0.4",
|
||||||
"@types/swagger-ui-express": "^4.1.6",
|
"@types/swagger-ui-express": "^4.1.6",
|
||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||||
|
"@typescript-eslint/parser": "^6.21.0",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
"nodemon": "^3.0.2",
|
"nodemon": "^3.0.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import swaggerJsdoc from 'swagger-jsdoc';
|
import swaggerJsdoc from 'swagger-jsdoc';
|
||||||
import { config } from './environment';
|
|
||||||
|
|
||||||
const options: swaggerJsdoc.Options = {
|
const options: swaggerJsdoc.Options = {
|
||||||
definition: {
|
definition: {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { AuthRequest } from '../middleware/auth';
|
import { AuthRequest } from '../middleware/auth';
|
||||||
import { mainPool } from '../config/database';
|
import { mainPool } from '../config/database';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
export const getApiKeys = async (req: AuthRequest, res: Response) => {
|
export const getApiKeys = async (req: AuthRequest, res: Response) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import jwt, { SignOptions } from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mainPool } from '../config/database';
|
import { mainPool } from '../config/database';
|
||||||
import { config } from '../config/environment';
|
import { config } from '../config/environment';
|
||||||
|
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ export const testDatabaseConnection = async (req: AuthRequest, res: Response) =>
|
|||||||
const dbType = dbResult.rows[0].type;
|
const dbType = dbResult.rows[0].type;
|
||||||
|
|
||||||
if (dbType === 'aql') {
|
if (dbType === 'aql') {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const { aqlExecutor } = require('../services/AqlExecutor');
|
const { aqlExecutor } = require('../services/AqlExecutor');
|
||||||
const result = await aqlExecutor.testConnection(id, env);
|
const result = await aqlExecutor.testConnection(id, env);
|
||||||
|
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
|
|||||||
return res.status(500).json({ error: 'AQL configuration is incomplete' });
|
return res.status(500).json({ error: 'AQL configuration is incomplete' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const { aqlExecutor } = require('../services/AqlExecutor');
|
const { aqlExecutor } = require('../services/AqlExecutor');
|
||||||
result = await aqlExecutor.executeAqlQuery(endpoint.database_id, {
|
result = await aqlExecutor.executeAqlQuery(endpoint.database_id, {
|
||||||
method: aqlMethod,
|
method: aqlMethod,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { AuthRequest } from '../middleware/auth';
|
import { AuthRequest } from '../middleware/auth';
|
||||||
import { mainPool } from '../config/database';
|
import { mainPool } from '../config/database';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { ExportedEndpoint, ExportedScriptQuery, ScriptExecutionError, Environment } from '../types';
|
import { ExportedEndpoint, ExportedScriptQuery, ScriptExecutionError, Environment } from '../types';
|
||||||
import { encryptEndpointData, decryptEndpointData } from '../services/endpointCrypto';
|
import { encryptEndpointData, decryptEndpointData } from '../services/endpointCrypto';
|
||||||
import { versionService } from '../services/VersionService';
|
import { versionService } from '../services/VersionService';
|
||||||
@@ -325,6 +324,7 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const { sqlExecutor } = require('../services/SqlExecutor');
|
const { sqlExecutor } = require('../services/SqlExecutor');
|
||||||
const result = await sqlExecutor.executeQuery(database_id, processedQuery, parameters || [], environment);
|
const result = await sqlExecutor.executeQuery(database_id, processedQuery, parameters || [], environment);
|
||||||
|
|
||||||
@@ -352,6 +352,7 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const { scriptExecutor } = require('../services/ScriptExecutor');
|
const { scriptExecutor } = require('../services/ScriptExecutor');
|
||||||
const scriptResult = await scriptExecutor.execute(script_language, script_code, {
|
const scriptResult = await scriptExecutor.execute(script_language, script_code, {
|
||||||
databaseId: database_id,
|
databaseId: database_id,
|
||||||
@@ -383,6 +384,7 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const { aqlExecutor } = require('../services/AqlExecutor');
|
const { aqlExecutor } = require('../services/AqlExecutor');
|
||||||
const result = await aqlExecutor.executeAqlQuery(database_id, {
|
const result = await aqlExecutor.executeAqlQuery(database_id, {
|
||||||
method: aql_method,
|
method: aql_method,
|
||||||
@@ -512,8 +514,8 @@ export const exportEndpoint = async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
const encrypted = encryptEndpointData(exportData);
|
const encrypted = encryptEndpointData(exportData);
|
||||||
|
|
||||||
const safeFileName = endpoint.name.replace(/[^a-zA-Z0-9_\-]/g, '_');
|
const safeFileName = endpoint.name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
const encodedFileName = encodeURIComponent(endpoint.name.replace(/[\/\\:*?"<>|]/g, '_')) + '.kabe';
|
const encodedFileName = encodeURIComponent(endpoint.name.replace(/[/\\:*?"<>|]/g, '_')) + '.kabe';
|
||||||
res.setHeader('Content-Type', 'application/octet-stream');
|
res.setHeader('Content-Type', 'application/octet-stream');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${safeFileName}.kabe"; filename*=UTF-8''${encodedFileName}`);
|
res.setHeader('Content-Disposition', `attachment; filename="${safeFileName}.kabe"; filename*=UTF-8''${encodedFileName}`);
|
||||||
res.send(encrypted);
|
res.send(encrypted);
|
||||||
|
|||||||
155
backend/src/controllers/giteaController.ts
Normal file
155
backend/src/controllers/giteaController.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import { AuthRequest } from '../middleware/auth';
|
||||||
|
import { settingsService } from '../services/SettingsService';
|
||||||
|
import { giteaService } from '../services/GiteaService';
|
||||||
|
|
||||||
|
export const getSettings = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const settings = await settingsService.getGiteaSettings();
|
||||||
|
if (!settings) return res.json({ enabled: false, url: '', token: '', owner: '', repo: '', prod_branch: 'main' });
|
||||||
|
// Mask token
|
||||||
|
const masked = settings.token
|
||||||
|
? '****' + settings.token.slice(-4)
|
||||||
|
: '';
|
||||||
|
res.json({ ...settings, token: masked });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateSettings = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { enabled, url, token, owner, repo, prod_branch } = req.body;
|
||||||
|
|
||||||
|
// If token is masked (starts with ****), keep existing
|
||||||
|
let actualToken = token;
|
||||||
|
if (token?.startsWith('****')) {
|
||||||
|
const existing = await settingsService.getGiteaSettings();
|
||||||
|
actualToken = existing?.token || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
await settingsService.setGiteaSettings({
|
||||||
|
enabled: enabled ?? false,
|
||||||
|
url: url || '',
|
||||||
|
token: actualToken || '',
|
||||||
|
owner: owner || '',
|
||||||
|
repo: repo || '',
|
||||||
|
prod_branch: prod_branch || 'main',
|
||||||
|
}, req.user!.id);
|
||||||
|
|
||||||
|
giteaService.invalidateCache();
|
||||||
|
|
||||||
|
res.json({ message: 'Settings saved' });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const testGiteaConnection = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await giteaService.testConnection();
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syncAll = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await giteaService.initializeRepo();
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSyncStatus = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const status = await giteaService.getSyncStatus();
|
||||||
|
res.json(status);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listBranches = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const branches = await giteaService.listBranches();
|
||||||
|
res.json(branches);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createBranch = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name, from } = req.body;
|
||||||
|
if (!name) return res.status(400).json({ error: 'Branch name required' });
|
||||||
|
await giteaService.createBranch(name, from);
|
||||||
|
res.json({ message: `Branch ${name} created` });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteBranch = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
await giteaService.deleteBranch(name);
|
||||||
|
res.json({ message: `Branch ${name} deleted` });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const compareBranches = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { base, head } = req.query as { base: string; head: string };
|
||||||
|
if (!base || !head) return res.status(400).json({ error: 'base and head required' });
|
||||||
|
const diff = await giteaService.compareBranches(base, head);
|
||||||
|
res.json(diff);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCommitHistory = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { path, branch } = req.query as { path?: string; branch?: string };
|
||||||
|
const commits = await giteaService.getCommitHistory(path, branch);
|
||||||
|
res.json(commits);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEndpointInfo = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const info = await giteaService.getEndpointGiteaInfo(id);
|
||||||
|
res.json(info || { enabled: false });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPR = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { title, head, base, body } = req.body;
|
||||||
|
if (!title || !head) return res.status(400).json({ error: 'title and head branch required' });
|
||||||
|
const pr = await giteaService.createPR(title, head, base, body);
|
||||||
|
res.json(pr);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mergePR = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { index } = req.params;
|
||||||
|
await giteaService.mergePR(parseInt(index));
|
||||||
|
res.json({ message: 'PR merged' });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
10
backend/src/migrations/012_add_app_settings.sql
Normal file
10
backend/src/migrations/012_add_app_settings.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- Generic application settings (key-value store)
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
key VARCHAR(255) PRIMARY KEY,
|
||||||
|
value JSONB NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by UUID REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Track Gitea commit SHA in endpoint versions
|
||||||
|
ALTER TABLE endpoint_versions ADD COLUMN IF NOT EXISTS gitea_commit_sha VARCHAR(64);
|
||||||
48
backend/src/routes/gitea.ts
Normal file
48
backend/src/routes/gitea.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
|
import {
|
||||||
|
getSettings,
|
||||||
|
updateSettings,
|
||||||
|
testGiteaConnection,
|
||||||
|
syncAll,
|
||||||
|
getSyncStatus,
|
||||||
|
listBranches,
|
||||||
|
createBranch,
|
||||||
|
deleteBranch,
|
||||||
|
compareBranches,
|
||||||
|
getCommitHistory,
|
||||||
|
getEndpointInfo,
|
||||||
|
createPR,
|
||||||
|
mergePR,
|
||||||
|
} from '../controllers/giteaController';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
router.get('/settings', getSettings);
|
||||||
|
router.put('/settings', updateSettings);
|
||||||
|
router.post('/test', testGiteaConnection);
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
router.post('/sync-all', syncAll);
|
||||||
|
router.get('/sync-status', getSyncStatus);
|
||||||
|
|
||||||
|
// Branches
|
||||||
|
router.get('/branches', listBranches);
|
||||||
|
router.post('/branches', createBranch);
|
||||||
|
router.delete('/branches/:name', deleteBranch);
|
||||||
|
|
||||||
|
// Diff & History
|
||||||
|
router.get('/compare', compareBranches);
|
||||||
|
router.get('/commits', getCommitHistory);
|
||||||
|
|
||||||
|
// Endpoint-specific
|
||||||
|
router.get('/endpoints/:id/info', getEndpointInfo);
|
||||||
|
|
||||||
|
// Pull Requests
|
||||||
|
router.post('/pulls', createPR);
|
||||||
|
router.post('/pulls/:index/merge', mergePR);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import express, { Express, Request, Response } from 'express';
|
import express, { Express, Request, Response } from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import helmet from 'helmet';
|
// import helmet from 'helmet';
|
||||||
// import rateLimit from 'express-rate-limit';
|
// import rateLimit from 'express-rate-limit';
|
||||||
import swaggerUi from 'swagger-ui-express';
|
import swaggerUi from 'swagger-ui-express';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -21,6 +21,7 @@ import logsRoutes from './routes/logs';
|
|||||||
import sqlInterfaceRoutes from './routes/sqlInterface';
|
import sqlInterfaceRoutes from './routes/sqlInterface';
|
||||||
import dynamicRoutes from './routes/dynamic';
|
import dynamicRoutes from './routes/dynamic';
|
||||||
import syncRoutes from './routes/sync';
|
import syncRoutes from './routes/sync';
|
||||||
|
import giteaRoutes from './routes/gitea';
|
||||||
|
|
||||||
const app: Express = express();
|
const app: Express = express();
|
||||||
|
|
||||||
@@ -95,6 +96,7 @@ app.use('/api/users', userRoutes);
|
|||||||
app.use('/api/logs', logsRoutes);
|
app.use('/api/logs', logsRoutes);
|
||||||
app.use('/api/workbench', sqlInterfaceRoutes);
|
app.use('/api/workbench', sqlInterfaceRoutes);
|
||||||
app.use('/api/sync', syncRoutes);
|
app.use('/api/sync', syncRoutes);
|
||||||
|
app.use('/api/gitea', giteaRoutes);
|
||||||
|
|
||||||
// Dynamic API routes (user-created endpoints)
|
// Dynamic API routes (user-created endpoints)
|
||||||
app.use('/api/v1', dynamicRoutes);
|
app.use('/api/v1', dynamicRoutes);
|
||||||
@@ -107,16 +109,21 @@ if (config.nodeEnv === 'production') {
|
|||||||
app.use(express.static(frontendPath, {
|
app.use(express.static(frontendPath, {
|
||||||
maxAge: '1d',
|
maxAge: '1d',
|
||||||
etag: true,
|
etag: true,
|
||||||
setHeaders: (res) => {
|
setHeaders: (res, filePath) => {
|
||||||
|
// index.html must not be cached — it references hashed assets
|
||||||
|
if (filePath.endsWith('.html')) {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
} else {
|
||||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// SPA fallback - all non-API routes serve index.html
|
// SPA fallback - all non-API routes serve index.html
|
||||||
app.get('*', (req: Request, res: Response) => {
|
app.get('*', (req: Request, res: Response) => {
|
||||||
if (!req.path.startsWith('/api/') && !req.path.startsWith('/api-docs') && req.path !== '/health') {
|
if (!req.path.startsWith('/api/') && !req.path.startsWith('/api-docs') && req.path !== '/health') {
|
||||||
const indexPath = path.join(frontendPath, 'index.html');
|
const indexPath = path.join(frontendPath, 'index.html');
|
||||||
console.log(`📄 Serving index.html for route: ${req.path}`);
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
res.sendFile(indexPath);
|
res.sendFile(indexPath);
|
||||||
} else {
|
} else {
|
||||||
res.status(404).json({ error: 'API route not found' });
|
res.status(404).json({ error: 'API route not found' });
|
||||||
|
|||||||
459
backend/src/services/GiteaService.ts
Normal file
459
backend/src/services/GiteaService.ts
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
import { settingsService } from './SettingsService';
|
||||||
|
import { mainPool } from '../config/database';
|
||||||
|
import { GiteaSettings, GiteaBranch, GiteaCommit, GiteaDiff } from '../types';
|
||||||
|
|
||||||
|
class GiteaService {
|
||||||
|
private cachedConfig: GiteaSettings | null = null;
|
||||||
|
private cacheTime = 0;
|
||||||
|
private readonly CACHE_TTL = 60000;
|
||||||
|
|
||||||
|
invalidateCache() {
|
||||||
|
this.cachedConfig = null;
|
||||||
|
this.cacheTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConfig(): Promise<GiteaSettings | null> {
|
||||||
|
if (this.cachedConfig && Date.now() - this.cacheTime < this.CACHE_TTL) {
|
||||||
|
return this.cachedConfig;
|
||||||
|
}
|
||||||
|
this.cachedConfig = await settingsService.getGiteaSettings();
|
||||||
|
this.cacheTime = Date.now();
|
||||||
|
return this.cachedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async isEnabled(): Promise<boolean> {
|
||||||
|
const cfg = await this.getConfig();
|
||||||
|
return !!(cfg?.enabled && cfg.url && cfg.token && cfg.owner && cfg.repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async api(method: string, path: string, body?: any): Promise<any> {
|
||||||
|
const cfg = await this.getConfig();
|
||||||
|
if (!cfg) throw new Error('Gitea not configured');
|
||||||
|
|
||||||
|
const url = `${cfg.url.replace(/\/+$/, '')}/api/v1${path}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Authorization': `token ${cfg.token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
|
||||||
|
const data: any = await res.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = data?.message || `Gitea API error ${res.status}`;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Repo management ---
|
||||||
|
|
||||||
|
async ensureRepo(): Promise<void> {
|
||||||
|
const cfg = await this.getConfig();
|
||||||
|
if (!cfg) throw new Error('Gitea not configured');
|
||||||
|
|
||||||
|
const existing = await this.api('GET', `/repos/${cfg.owner}/${cfg.repo}`);
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
// Try creating under user first, then org
|
||||||
|
try {
|
||||||
|
await this.api('POST', '/user/repos', {
|
||||||
|
name: cfg.repo,
|
||||||
|
description: 'KIS API Builder Endpoints',
|
||||||
|
private: true,
|
||||||
|
auto_init: true,
|
||||||
|
default_branch: cfg.prod_branch || 'main',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
await this.api('POST', `/orgs/${cfg.owner}/repos`, {
|
||||||
|
name: cfg.repo,
|
||||||
|
description: 'KIS API Builder Endpoints',
|
||||||
|
private: true,
|
||||||
|
auto_init: true,
|
||||||
|
default_branch: cfg.prod_branch || 'main',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async testConnection(): Promise<{ success: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
const cfg = await this.getConfig();
|
||||||
|
if (!cfg) return { success: false, message: 'Gitea not configured' };
|
||||||
|
|
||||||
|
const user = await this.api('GET', '/user');
|
||||||
|
if (!user) return { success: false, message: 'Failed to authenticate' };
|
||||||
|
|
||||||
|
await this.ensureRepo();
|
||||||
|
return { success: true, message: `Connected as ${user.login}, repo ready` };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, message: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- File operations ---
|
||||||
|
|
||||||
|
private async getFileSha(filePath: string, branch: string): Promise<string | null> {
|
||||||
|
const cfg = await this.getConfig();
|
||||||
|
if (!cfg) return null;
|
||||||
|
const data = await this.api('GET', `/repos/${cfg.owner}/${cfg.repo}/contents/${filePath}?ref=${encodeURIComponent(branch)}`);
|
||||||
|
return data?.sha || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async commitFile(filePath: string, content: string, message: string, branch: string): Promise<string | null> {
|
||||||
|
const cfg = await this.getConfig();
|
||||||
|
if (!cfg) return null;
|
||||||
|
|
||||||
|
const encoded = Buffer.from(content, 'utf-8').toString('base64');
|
||||||
|
const sha = await this.getFileSha(filePath, branch);
|
||||||
|
|
||||||
|
const body: any = {
|
||||||
|
content: encoded,
|
||||||
|
message,
|
||||||
|
branch,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sha) {
|
||||||
|
body.sha = sha;
|
||||||
|
const res = await this.api('PUT', `/repos/${cfg.owner}/${cfg.repo}/contents/${filePath}`, body);
|
||||||
|
return res?.content?.sha || null;
|
||||||
|
} else {
|
||||||
|
const res = await this.api('POST', `/repos/${cfg.owner}/${cfg.repo}/contents/${filePath}`, body);
|
||||||
|
return res?.content?.sha || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteFile(filePath: string, branch: string, message: string): Promise<void> {
|
||||||
|
const cfg = await this.getConfig();
|
||||||
|
if (!cfg) return;
|
||||||
|
|
||||||
|
const sha = await this.getFileSha(filePath, branch);
|
||||||
|
if (!sha) return;
|
||||||
|
|
||||||
|
await this.api('DELETE', `/repos/${cfg.owner}/${cfg.repo}/contents/${filePath}`, {
|
||||||
|
sha,
|
||||||
|
message,
|
||||||
|
branch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async commitEndpointFiles(
|
||||||
|
endpoint: any,
|
||||||
|
branch: string,
|
||||||
|
message: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!await this.isEnabled()) return null;
|
||||||
|
|
||||||
|
const folderName = endpoint.folder_name ? this.sanitize(endpoint.folder_name) : '_root';
|
||||||
|
const epName = this.sanitize(endpoint.name);
|
||||||
|
const basePath = `endpoints/${folderName}/${epName}`;
|
||||||
|
|
||||||
|
// endpoint.json
|
||||||
|
const meta = {
|
||||||
|
name: endpoint.name,
|
||||||
|
description: endpoint.description || '',
|
||||||
|
method: endpoint.method,
|
||||||
|
path: endpoint.path,
|
||||||
|
execution_type: endpoint.execution_type || 'sql',
|
||||||
|
database_name: endpoint.database_name || null,
|
||||||
|
parameters: endpoint.parameters || [],
|
||||||
|
is_public: endpoint.is_public || false,
|
||||||
|
enable_logging: endpoint.enable_logging || false,
|
||||||
|
detailed_response: endpoint.detailed_response || false,
|
||||||
|
response_schema: endpoint.response_schema || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let lastSha = await this.commitFile(
|
||||||
|
`${basePath}/endpoint.json`,
|
||||||
|
JSON.stringify(meta, null, 2),
|
||||||
|
message,
|
||||||
|
branch
|
||||||
|
);
|
||||||
|
|
||||||
|
// SQL query
|
||||||
|
if (endpoint.execution_type === 'sql' && endpoint.sql_query) {
|
||||||
|
lastSha = await this.commitFile(`${basePath}/query.sql`, endpoint.sql_query, message, branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script
|
||||||
|
if (endpoint.execution_type === 'script' && endpoint.script_code) {
|
||||||
|
const ext = endpoint.script_language === 'python' ? 'py' : 'js';
|
||||||
|
lastSha = await this.commitFile(`${basePath}/main.${ext}`, endpoint.script_code, message, branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AQL
|
||||||
|
if (endpoint.execution_type === 'aql') {
|
||||||
|
const method = endpoint.aql_method || 'GET';
|
||||||
|
const ep = endpoint.aql_endpoint || '';
|
||||||
|
const body = endpoint.aql_body || '';
|
||||||
|
let url = ep;
|
||||||
|
const qp = endpoint.aql_query_params || {};
|
||||||
|
const params = Object.entries(qp).map(([k, v]) => `${k}=${v}`).join('&');
|
||||||
|
if (params) url += (url.includes('?') ? '&' : '?') + params;
|
||||||
|
const httpContent = `${method} ${url}\nContent-Type: application/json\n${body ? '\n' + body + '\n' : ''}`;
|
||||||
|
lastSha = await this.commitFile(`${basePath}/request.http`, httpContent, message, branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script queries
|
||||||
|
const scriptQueries = endpoint.script_queries || [];
|
||||||
|
if (endpoint.execution_type === 'script' && scriptQueries.length > 0) {
|
||||||
|
const queryIndex: any[] = [];
|
||||||
|
for (const sq of scriptQueries) {
|
||||||
|
const sqMeta: any = { name: sq.name, database_id: sq.database_id || null };
|
||||||
|
if (sq.sql) {
|
||||||
|
const fileName = `${this.sanitize(sq.name)}.sql`;
|
||||||
|
await this.commitFile(`${basePath}/queries/${fileName}`, sq.sql, message, branch);
|
||||||
|
sqMeta.file = fileName;
|
||||||
|
}
|
||||||
|
if (sq.aql_method) {
|
||||||
|
const fileName = `${this.sanitize(sq.name)}.http`;
|
||||||
|
const httpContent = `${sq.aql_method} ${sq.aql_endpoint || ''}\nContent-Type: application/json\n${sq.aql_body ? '\n' + sq.aql_body + '\n' : ''}`;
|
||||||
|
await this.commitFile(`${basePath}/queries/${fileName}`, httpContent, message, branch);
|
||||||
|
sqMeta.file = fileName;
|
||||||
|
sqMeta.type = 'aql';
|
||||||
|
}
|
||||||
|
queryIndex.push(sqMeta);
|
||||||
|
}
|
||||||
|
await this.commitFile(`${basePath}/queries/_index.json`, JSON.stringify(queryIndex, null, 2), message, branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastSha;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEndpointFiles(endpointName: string, folderName: string | null, branch: string): Promise<void> {
|
||||||
|
if (!await this.isEnabled()) return;
|
||||||
|
|
||||||
|
const folder = folderName ? this.sanitize(folderName) : '_root';
|
||||||
|
const basePath = `endpoints/${folder}/${this.sanitize(endpointName)}`;
|
||||||
|
const cfg = await this.getConfig();
|
||||||
|
if (!cfg) return;
|
||||||
|
|
||||||
|
// List files in the directory
|
||||||
|
const contents = await this.api('GET', `/repos/${cfg.owner}/${cfg.repo}/contents/${basePath}?ref=${encodeURIComponent(branch)}`);
|
||||||
|
if (!Array.isArray(contents)) return;
|
||||||
|
|
||||||
|
for (const file of contents) {
|
||||||
|
if (file.type === 'dir') {
|
||||||
|
// Recurse into subdirectories (queries/)
|
||||||
|
const subContents = await this.api('GET', `/repos/${cfg.owner}/${cfg.repo}/contents/${file.path}?ref=${encodeURIComponent(branch)}`);
|
||||||
|
if (Array.isArray(subContents)) {
|
||||||
|
for (const subFile of subContents) {
|
||||||
|
await this.deleteFile(subFile.path, branch, `Delete ${endpointName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.deleteFile(file.path, branch, `Delete ${endpointName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Branch management ---
|
||||||
|
|
||||||
|
async listBranches(): Promise<GiteaBranch[]> {
|
||||||
|
if (!await this.isEnabled()) return [];
|
||||||
|
const cfg = await this.getConfig();
|
||||||
|
if (!cfg) return [];
|
||||||
|
const data = await this.api('GET', `/repos/${cfg.owner}/${cfg.repo}/branches`);
|
||||||
|
if (!Array.isArray(data)) return [];
|
||||||
|
return data.map((b: any) => ({
|
||||||
|
name: b.name,
|
||||||
|
commit: { id: b.commit?.id || '', message: b.commit?.message || '', timestamp: b.commit?.timestamp || '' },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBranch(name: string, from?: string): Promise<void> {
|
||||||
|
if (!await this.isEnabled()) return;
|
||||||
|
const cfg = await this.getConfig();
|
||||||
|
if (!cfg) return;
|
||||||
|
await this.api('POST', `/repos/${cfg.owner}/${cfg.repo}/branches`, {
|
||||||
|
new_branch_name: name,
|
||||||
|
old_branch_name: from || cfg.prod_branch || 'main',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBranch(name: string): Promise<void> {
|
||||||
|
if (!await this.isEnabled()) return;
|
||||||
|
const cfg = await this.getConfig();
|
||||||
|
if (!cfg) return;
|
||||||
|
await this.api('DELETE', `/repos/${cfg.owner}/${cfg.repo}/branches/${encodeURIComponent(name)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- History ---
|
||||||
|
|
||||||
|
async getCommitHistory(filePath?: string, branch?: string): Promise<GiteaCommit[]> {
|
||||||
|
if (!await this.isEnabled()) return [];
|
||||||
|
const cfg = await this.getConfig();
|
||||||
|
if (!cfg) return [];
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filePath) params.set('path', filePath);
|
||||||
|
if (branch) params.set('sha', branch);
|
||||||
|
params.set('limit', '50');
|
||||||
|
|
||||||
|
const data = await this.api('GET', `/repos/${cfg.owner}/${cfg.repo}/commits?${params.toString()}`);
|
||||||
|
if (!Array.isArray(data)) return [];
|
||||||
|
|
||||||
|
return data.map((c: any) => ({
|
||||||
|
sha: c.sha,
|
||||||
|
message: c.commit?.message || '',
|
||||||
|
author: {
|
||||||
|
name: c.commit?.author?.name || '',
|
||||||
|
email: c.commit?.author?.email || '',
|
||||||
|
date: c.commit?.author?.date || '',
|
||||||
|
},
|
||||||
|
html_url: c.html_url || '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Diff ---
|
||||||
|
|
||||||
|
async compareBranches(base: string, head: string): Promise<GiteaDiff | null> {
|
||||||
|
if (!await this.isEnabled()) return null;
|
||||||
|
const cfg = await this.getConfig();
|
||||||
|
if (!cfg) return null;
|
||||||
|
|
||||||
|
const data = await this.api('GET', `/repos/${cfg.owner}/${cfg.repo}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`);
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_commits: data.total_commits || 0,
|
||||||
|
commits: (data.commits || []).map((c: any) => ({
|
||||||
|
sha: c.sha,
|
||||||
|
message: c.commit?.message || '',
|
||||||
|
author: { name: c.commit?.author?.name || '', email: c.commit?.author?.email || '', date: c.commit?.author?.date || '' },
|
||||||
|
html_url: c.html_url || '',
|
||||||
|
})),
|
||||||
|
files: (data.files || []).map((f: any) => ({
|
||||||
|
filename: f.filename,
|
||||||
|
status: f.status,
|
||||||
|
additions: f.additions || 0,
|
||||||
|
deletions: f.deletions || 0,
|
||||||
|
patch: f.patch,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pull Requests ---
|
||||||
|
|
||||||
|
async createPR(title: string, headBranch: string, baseBranch?: string, body?: string): Promise<any> {
|
||||||
|
if (!await this.isEnabled()) return null;
|
||||||
|
const cfg = await this.getConfig();
|
||||||
|
if (!cfg) return null;
|
||||||
|
|
||||||
|
return this.api('POST', `/repos/${cfg.owner}/${cfg.repo}/pulls`, {
|
||||||
|
title,
|
||||||
|
head: headBranch,
|
||||||
|
base: baseBranch || cfg.prod_branch || 'main',
|
||||||
|
body: body || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async mergePR(prIndex: number, deleteBranch = true): Promise<void> {
|
||||||
|
if (!await this.isEnabled()) return;
|
||||||
|
const cfg = await this.getConfig();
|
||||||
|
if (!cfg) return;
|
||||||
|
|
||||||
|
await this.api('POST', `/repos/${cfg.owner}/${cfg.repo}/pulls/${prIndex}/merge`, {
|
||||||
|
Do: 'merge',
|
||||||
|
delete_branch_after_merge: deleteBranch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sync all endpoints ---
|
||||||
|
|
||||||
|
async initializeRepo(): Promise<{ synced: number; errors: string[] }> {
|
||||||
|
if (!await this.isEnabled()) throw new Error('Gitea not enabled');
|
||||||
|
|
||||||
|
await this.ensureRepo();
|
||||||
|
const cfg = await this.getConfig();
|
||||||
|
if (!cfg) throw new Error('Gitea not configured');
|
||||||
|
|
||||||
|
const result = await mainPool.query(
|
||||||
|
`SELECT e.*, f.name as folder_name, d.name as database_name
|
||||||
|
FROM endpoints e
|
||||||
|
LEFT JOIN folders f ON e.folder_id = f.id
|
||||||
|
LEFT JOIN databases d ON e.database_id = d.id`
|
||||||
|
);
|
||||||
|
|
||||||
|
let synced = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const ep of result.rows) {
|
||||||
|
try {
|
||||||
|
await this.commitEndpointFiles(ep, cfg.prod_branch || 'main', `Sync: ${ep.name}`);
|
||||||
|
synced++;
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`${ep.name}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { synced, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
async getEndpointGiteaInfo(endpointId: string): Promise<any> {
|
||||||
|
if (!await this.isEnabled()) return null;
|
||||||
|
const cfg = await this.getConfig();
|
||||||
|
if (!cfg) return null;
|
||||||
|
|
||||||
|
const epResult = await mainPool.query(
|
||||||
|
`SELECT e.name, e.path, f.name as folder_name
|
||||||
|
FROM endpoints e LEFT JOIN folders f ON e.folder_id = f.id
|
||||||
|
WHERE e.id = $1`,
|
||||||
|
[endpointId]
|
||||||
|
);
|
||||||
|
if (epResult.rows.length === 0) return null;
|
||||||
|
|
||||||
|
const ep = epResult.rows[0];
|
||||||
|
const folder = ep.folder_name ? this.sanitize(ep.folder_name) : '_root';
|
||||||
|
const repoPath = `endpoints/${folder}/${this.sanitize(ep.name)}`;
|
||||||
|
const fileUrl = `${cfg.url}/${cfg.owner}/${cfg.repo}/src/branch/${cfg.prod_branch}/${repoPath}`;
|
||||||
|
|
||||||
|
const versionResult = await mainPool.query(
|
||||||
|
`SELECT gitea_commit_sha FROM endpoint_versions
|
||||||
|
WHERE endpoint_id = $1 AND gitea_commit_sha IS NOT NULL
|
||||||
|
ORDER BY version_number DESC LIMIT 1`,
|
||||||
|
[endpointId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
url: cfg.url,
|
||||||
|
owner: cfg.owner,
|
||||||
|
repo: cfg.repo,
|
||||||
|
file_url: fileUrl,
|
||||||
|
repo_path: repoPath,
|
||||||
|
last_commit_sha: versionResult.rows[0]?.gitea_commit_sha || null,
|
||||||
|
commit_url: versionResult.rows[0]?.gitea_commit_sha
|
||||||
|
? `${cfg.url}/${cfg.owner}/${cfg.repo}/commit/${versionResult.rows[0].gitea_commit_sha}`
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSyncStatus(): Promise<{ total: number; synced: number; unsynced: number }> {
|
||||||
|
const totalResult = await mainPool.query('SELECT COUNT(*) FROM endpoints');
|
||||||
|
const syncedResult = await mainPool.query(
|
||||||
|
`SELECT COUNT(DISTINCT ev.endpoint_id) FROM endpoint_versions ev
|
||||||
|
WHERE ev.gitea_commit_sha IS NOT NULL`
|
||||||
|
);
|
||||||
|
const total = parseInt(totalResult.rows[0].count);
|
||||||
|
const synced = parseInt(syncedResult.rows[0].count);
|
||||||
|
return { total, synced, unsynced: total - synced };
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitize(name: string): string {
|
||||||
|
return name.replace(/[<>:"/\\|?*]/g, '_').replace(/\s+/g, '_').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const giteaService = new GiteaService();
|
||||||
@@ -185,7 +185,7 @@ export class IsolatedScriptExecutor {
|
|||||||
|
|
||||||
// Capped setTimeout/clearTimeout
|
// Capped setTimeout/clearTimeout
|
||||||
const timerIds = new Set<ReturnType<typeof setTimeout>>();
|
const timerIds = new Set<ReturnType<typeof setTimeout>>();
|
||||||
sandbox.setTimeout = (fn: Function, ms: number, ...args: any[]) => {
|
sandbox.setTimeout = (fn: (...args: any[]) => void, ms: number, ...args: any[]) => {
|
||||||
const cappedMs = Math.min(ms || 0, 30000);
|
const cappedMs = Math.min(ms || 0, 30000);
|
||||||
const id = setTimeout(() => {
|
const id = setTimeout(() => {
|
||||||
timerIds.delete(id);
|
timerIds.delete(id);
|
||||||
|
|||||||
64
backend/src/services/SettingsService.ts
Normal file
64
backend/src/services/SettingsService.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { mainPool } from '../config/database';
|
||||||
|
import { GiteaSettings } from '../types';
|
||||||
|
import { config } from '../config/environment';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
|
||||||
|
function getEncryptionKey(): Buffer {
|
||||||
|
return crypto.createHash('sha256').update(config.jwt.secret + ':settings').digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
function encrypt(text: string): string {
|
||||||
|
const key = getEncryptionKey();
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted.toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrypt(data: string): string {
|
||||||
|
const key = getEncryptionKey();
|
||||||
|
const [ivHex, authTagHex, encryptedHex] = data.split(':');
|
||||||
|
const iv = Buffer.from(ivHex, 'hex');
|
||||||
|
const authTag = Buffer.from(authTagHex, 'hex');
|
||||||
|
const encrypted = Buffer.from(encryptedHex, 'hex');
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
class SettingsService {
|
||||||
|
async get<T>(key: string): Promise<T | null> {
|
||||||
|
const result = await mainPool.query('SELECT value FROM app_settings WHERE key = $1', [key]);
|
||||||
|
if (result.rows.length === 0) return null;
|
||||||
|
return result.rows[0].value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: any, userId?: string): Promise<void> {
|
||||||
|
await mainPool.query(
|
||||||
|
`INSERT INTO app_settings (key, value, updated_at, updated_by)
|
||||||
|
VALUES ($1, $2, CURRENT_TIMESTAMP, $3)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP, updated_by = $3`,
|
||||||
|
[key, JSON.stringify(value), userId || null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGiteaSettings(): Promise<GiteaSettings | null> {
|
||||||
|
const raw = await this.get<any>('gitea');
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return { ...raw, token: raw.token ? decrypt(raw.token) : '' };
|
||||||
|
} catch {
|
||||||
|
return { ...raw, token: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setGiteaSettings(settings: GiteaSettings, userId: string): Promise<void> {
|
||||||
|
const toStore = { ...settings, token: settings.token ? encrypt(settings.token) : '' };
|
||||||
|
await this.set('gitea', toStore, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const settingsService = new SettingsService();
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { mainPool } from '../config/database';
|
import { mainPool } from '../config/database';
|
||||||
import { EndpointVersion, VersionStatus } from '../types';
|
import { EndpointVersion, VersionStatus } from '../types';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
import { giteaService } from './GiteaService';
|
||||||
|
|
||||||
const SNAPSHOT_FIELDS = [
|
const SNAPSHOT_FIELDS = [
|
||||||
'name', 'description', 'method', 'path', 'database_id', 'sql_query',
|
'name', 'description', 'method', 'path', 'database_id', 'sql_query',
|
||||||
@@ -81,6 +82,7 @@ class VersionService {
|
|||||||
updateParams
|
updateParams
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.syncToGitea(endpointId, version, status).catch(() => {});
|
||||||
return version;
|
return version;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,10 +142,11 @@ class VersionService {
|
|||||||
[nextVersion, version.id, endpointId]
|
[nextVersion, version.id, endpointId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.syncToGitea(endpointId, version, 'draft').catch(() => {});
|
||||||
return version;
|
return version;
|
||||||
}
|
}
|
||||||
|
|
||||||
async publishVersion(versionId: string, userId: string): Promise<void> {
|
async publishVersion(versionId: string, _userId: string): Promise<void> {
|
||||||
const vResult = await mainPool.query(
|
const vResult = await mainPool.query(
|
||||||
'SELECT * FROM endpoint_versions WHERE id = $1',
|
'SELECT * FROM endpoint_versions WHERE id = $1',
|
||||||
[versionId]
|
[versionId]
|
||||||
@@ -188,6 +191,8 @@ class VersionService {
|
|||||||
versionId, version.endpoint_id
|
versionId, version.endpoint_id
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.syncToGitea(version.endpoint_id, version, 'published').catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async rollbackToVersion(
|
async rollbackToVersion(
|
||||||
@@ -301,6 +306,46 @@ class VersionService {
|
|||||||
);
|
);
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async syncToGitea(endpointId: string, version: EndpointVersion, status: VersionStatus): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!await giteaService.isEnabled()) return;
|
||||||
|
|
||||||
|
const cfg = await giteaService.getConfig();
|
||||||
|
if (!cfg) return;
|
||||||
|
|
||||||
|
const epResult = await mainPool.query(
|
||||||
|
`SELECT e.*, f.name as folder_name, d.name as database_name
|
||||||
|
FROM endpoints e
|
||||||
|
LEFT JOIN folders f ON e.folder_id = f.id
|
||||||
|
LEFT JOIN databases d ON e.database_id = d.id
|
||||||
|
WHERE e.id = $1`,
|
||||||
|
[endpointId]
|
||||||
|
);
|
||||||
|
if (epResult.rows.length === 0) return;
|
||||||
|
|
||||||
|
const ep = { ...epResult.rows[0], ...version };
|
||||||
|
const branch = status === 'published'
|
||||||
|
? cfg.prod_branch || 'main'
|
||||||
|
: `drafts/${giteaService['sanitize'](version.name)}`;
|
||||||
|
|
||||||
|
if (status === 'draft') {
|
||||||
|
try { await giteaService.createBranch(branch, cfg.prod_branch || 'main'); } catch { /* branch may already exist */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = `v${version.version_number}: ${version.change_message || status}`;
|
||||||
|
const sha = await giteaService.commitEndpointFiles(ep, branch, message);
|
||||||
|
|
||||||
|
if (sha) {
|
||||||
|
await mainPool.query(
|
||||||
|
'UPDATE endpoint_versions SET gitea_commit_sha = $1 WHERE id = $2',
|
||||||
|
[sha, version.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Gitea sync failed (non-blocking):', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const versionService = new VersionService();
|
export const versionService = new VersionService();
|
||||||
|
|||||||
@@ -138,6 +138,39 @@ export interface IsolatedExecutionResult {
|
|||||||
queries: QueryExecution[];
|
queries: QueryExecution[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GiteaSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
prod_branch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaBranch {
|
||||||
|
name: string;
|
||||||
|
commit: { id: string; message: string; timestamp: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaCommit {
|
||||||
|
sha: string;
|
||||||
|
message: string;
|
||||||
|
author: { name: string; email: string; date: string };
|
||||||
|
html_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaDiff {
|
||||||
|
total_commits: number;
|
||||||
|
commits: GiteaCommit[];
|
||||||
|
files: Array<{
|
||||||
|
filename: string;
|
||||||
|
status: string;
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
patch?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
export class ScriptExecutionError extends Error {
|
export class ScriptExecutionError extends Error {
|
||||||
logs: LogEntry[];
|
logs: LogEntry[];
|
||||||
queries: QueryExecution[];
|
queries: QueryExecution[];
|
||||||
|
|||||||
44
docker-compose.gitea.yml
Normal file
44
docker-compose.gitea.yml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# ============================================
|
||||||
|
# KIS API Builder - Optional Gitea Integration
|
||||||
|
# ============================================
|
||||||
|
# Use alongside the main docker-compose:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.gitea.yml up -d
|
||||||
|
#
|
||||||
|
# For existing Gitea: skip this file, configure via Settings UI
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
gitea:
|
||||||
|
image: gitea/gitea:1.22-rootless
|
||||||
|
container_name: kis-api-builder-gitea
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- GITEA__database__DB_TYPE=sqlite3
|
||||||
|
- GITEA__server__ROOT_URL=${GITEA_URL:-http://localhost:${GITEA_PORT:-8010}}
|
||||||
|
- GITEA__server__HTTP_PORT=3000
|
||||||
|
- GITEA__security__INSTALL_LOCK=true
|
||||||
|
- GITEA__service__DISABLE_REGISTRATION=true
|
||||||
|
volumes:
|
||||||
|
- gitea_data:/var/lib/gitea
|
||||||
|
- gitea_config:/etc/gitea
|
||||||
|
ports:
|
||||||
|
- "${GITEA_PORT:-8010}:3000"
|
||||||
|
networks:
|
||||||
|
- kis-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3000/api/v1/version"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
app:
|
||||||
|
environment:
|
||||||
|
GITEA_URL: ${GITEA_URL:-http://gitea:3000}
|
||||||
|
depends_on:
|
||||||
|
gitea:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
gitea_data:
|
||||||
|
gitea_config:
|
||||||
19
frontend/.eslintrc.cjs
Normal file
19
frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: { browser: true, es2020: true },
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:react-hooks/recommended',
|
||||||
|
],
|
||||||
|
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
plugins: ['react-refresh'],
|
||||||
|
rules: {
|
||||||
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||||
|
'no-console': 'off',
|
||||||
|
'prefer-const': 'warn',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { endpointsApi, foldersApi, databasesApi, versionsApi } from '@/services/api';
|
import { endpointsApi, foldersApi, databasesApi, versionsApi, giteaApi } from '@/services/api';
|
||||||
import { EndpointParameter, QueryTestResult, LogEntry, QueryExecution, EndpointVersion } from '@/types';
|
import { EndpointParameter, QueryTestResult, LogEntry, QueryExecution, EndpointVersion } from '@/types';
|
||||||
import { Plus, Trash2, Play, Edit2, ChevronDown, ChevronUp, ArrowLeft, CheckCircle, XCircle, Clock, Copy, X, Terminal, History, RotateCcw, Upload } from 'lucide-react';
|
import { Plus, Trash2, Play, Edit2, ChevronDown, ChevronUp, ArrowLeft, CheckCircle, XCircle, Clock, Copy, X, Terminal, History, RotateCcw, Upload, GitBranch } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import SqlEditor from '@/components/SqlEditor';
|
import SqlEditor from '@/components/SqlEditor';
|
||||||
import CodeEditor from '@/components/CodeEditor';
|
import CodeEditor from '@/components/CodeEditor';
|
||||||
@@ -105,7 +105,7 @@ export default function EndpointEditor() {
|
|||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(storageKey);
|
const saved = localStorage.getItem(storageKey);
|
||||||
if (saved) return JSON.parse(saved).testParams || {};
|
if (saved) return JSON.parse(saved).testParams || {};
|
||||||
} catch {}
|
} catch { /* ignore corrupt localStorage */ }
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
});
|
});
|
||||||
@@ -134,7 +134,7 @@ export default function EndpointEditor() {
|
|||||||
const parsed = JSON.parse(saved);
|
const parsed = JSON.parse(saved);
|
||||||
if (parsed.testResult) setTestResult(parsed.testResult);
|
if (parsed.testResult) setTestResult(parsed.testResult);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
}, [storageKey]);
|
}, [storageKey]);
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ export default function EndpointEditor() {
|
|||||||
if (storageKey) {
|
if (storageKey) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(storageKey, JSON.stringify({ testParams, testResult }));
|
localStorage.setItem(storageKey, JSON.stringify({ testParams, testResult }));
|
||||||
} catch {}
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
}, [storageKey, testParams, testResult]);
|
}, [storageKey, testParams, testResult]);
|
||||||
|
|
||||||
@@ -242,6 +242,12 @@ export default function EndpointEditor() {
|
|||||||
});
|
});
|
||||||
const [showVersionHistory, setShowVersionHistory] = useState(false);
|
const [showVersionHistory, setShowVersionHistory] = useState(false);
|
||||||
|
|
||||||
|
const { data: giteaInfo } = useQuery({
|
||||||
|
queryKey: ['gitea-info', id],
|
||||||
|
queryFn: () => giteaApi.getEndpointInfo(id!).then(r => r.data),
|
||||||
|
enabled: isEditing,
|
||||||
|
});
|
||||||
|
|
||||||
const draftMutation = useMutation({
|
const draftMutation = useMutation({
|
||||||
mutationFn: () => versionsApi.saveDraft(id!, { ...formData, change_message: undefined }),
|
mutationFn: () => versionsApi.saveDraft(id!, { ...formData, change_message: undefined }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -1099,6 +1105,44 @@ export default function EndpointEditor() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Gitea info */}
|
||||||
|
{isEditing && giteaInfo?.enabled && (
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-3">
|
||||||
|
<GitBranch size={16} />
|
||||||
|
<span>Gitea</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
{giteaInfo.file_url && (
|
||||||
|
<a
|
||||||
|
href={giteaInfo.file_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block text-primary-600 hover:text-primary-700 truncate"
|
||||||
|
>
|
||||||
|
View in Gitea
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{giteaInfo.last_commit_sha && (
|
||||||
|
<div className="flex items-center gap-2 text-gray-500">
|
||||||
|
<span>Last commit:</span>
|
||||||
|
<a
|
||||||
|
href={giteaInfo.commit_url || '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-mono text-primary-600 hover:text-primary-700"
|
||||||
|
>
|
||||||
|
{giteaInfo.last_commit_sha.slice(0, 7)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!giteaInfo.last_commit_sha && (
|
||||||
|
<span className="text-gray-400">Not yet synced to Gitea</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Test results */}
|
{/* Test results */}
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div className="card overflow-hidden">
|
<div className="card overflow-hidden">
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { usersApi, dbManagementApi } from '@/services/api';
|
import { usersApi, dbManagementApi, giteaApi } from '@/services/api';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { User, Lock, UserCircle, Database, Plus, Edit2, Trash2, Eye, EyeOff, Users } from 'lucide-react';
|
import { User, Lock, UserCircle, Database, Plus, Edit2, Trash2, Eye, EyeOff, Users, GitBranch } from 'lucide-react';
|
||||||
import Dialog from '@/components/Dialog';
|
import Dialog from '@/components/Dialog';
|
||||||
import CodeEditor from '@/components/CodeEditor';
|
import CodeEditor from '@/components/CodeEditor';
|
||||||
|
|
||||||
@@ -216,7 +216,7 @@ function PasswordTab({ currentUser }: { currentUser: any }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GlobalSettingsTab() {
|
function GlobalSettingsTab() {
|
||||||
const [subTab, setSubTab] = useState<'databases' | 'users'>('databases');
|
const [subTab, setSubTab] = useState<'databases' | 'users' | 'gitea'>('databases');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -244,11 +244,262 @@ function GlobalSettingsTab() {
|
|||||||
<Users className="inline mr-2" size={16} />
|
<Users className="inline mr-2" size={16} />
|
||||||
Пользователи
|
Пользователи
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSubTab('gitea')}
|
||||||
|
className={`py-3 px-2 border-b-2 font-medium transition-colors ${
|
||||||
|
subTab === 'gitea'
|
||||||
|
? 'border-primary-600 text-primary-600'
|
||||||
|
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<GitBranch className="inline mr-2" size={16} />
|
||||||
|
Gitea
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{subTab === 'databases' && <DatabasesSubTab />}
|
{subTab === 'databases' && <DatabasesSubTab />}
|
||||||
{subTab === 'users' && <UsersSubTab />}
|
{subTab === 'users' && <UsersSubTab />}
|
||||||
|
{subTab === 'gitea' && <GiteaSubTab />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GiteaSubTab() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
const { data: settings, isLoading } = useQuery({
|
||||||
|
queryKey: ['gitea-settings'],
|
||||||
|
queryFn: () => giteaApi.getSettings().then(r => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: syncStatus } = useQuery({
|
||||||
|
queryKey: ['gitea-sync-status'],
|
||||||
|
queryFn: () => giteaApi.getSyncStatus().then(r => r.data),
|
||||||
|
enabled: !!settings?.enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: branches } = useQuery({
|
||||||
|
queryKey: ['gitea-branches'],
|
||||||
|
queryFn: () => giteaApi.listBranches().then(r => r.data),
|
||||||
|
enabled: !!settings?.enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
enabled: false, url: '', token: '', owner: '', repo: '', prod_branch: 'main',
|
||||||
|
});
|
||||||
|
|
||||||
|
useState(() => {
|
||||||
|
if (settings) setFormData(settings);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync form when settings load
|
||||||
|
const prevSettings = settings;
|
||||||
|
if (prevSettings && formData.url === '' && prevSettings.url !== '') {
|
||||||
|
setFormData(prevSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => giteaApi.updateSettings(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['gitea-settings'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['gitea-branches'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['gitea-sync-status'] });
|
||||||
|
toast.success('Gitea settings saved');
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to save Gitea settings'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const testMutation = useMutation({
|
||||||
|
mutationFn: () => giteaApi.testConnection(),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
if (res.data.success) toast.success(res.data.message);
|
||||||
|
else toast.error(res.data.message);
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Connection test failed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncMutation = useMutation({
|
||||||
|
mutationFn: () => giteaApi.syncAll(),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
toast.success(`Synced ${res.data.synced} endpoints${res.data.errors?.length ? `, ${res.data.errors.length} errors` : ''}`);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['gitea-sync-status'] });
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Sync failed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createBranchMutation = useMutation({
|
||||||
|
mutationFn: (name: string) => giteaApi.createBranch(name),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['gitea-branches'] });
|
||||||
|
toast.success('Branch created');
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to create branch'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBranchMutation = useMutation({
|
||||||
|
mutationFn: (name: string) => giteaApi.deleteBranch(name),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['gitea-branches'] });
|
||||||
|
toast.success('Branch deleted');
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to delete branch'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <div className="text-center py-8"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-1">Gitea Integration</h3>
|
||||||
|
<p className="text-sm text-gray-600">Version control for endpoints via Gitea (branches, merges, diffs, PR)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection form */}
|
||||||
|
<div className="border border-gray-200 rounded-lg p-4 space-y-4">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.enabled}
|
||||||
|
onChange={(e) => setFormData({ ...formData, enabled: e.target.checked })}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Enable Gitea integration</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Gitea URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.url}
|
||||||
|
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="http://gitea:3000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">API Token</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={formData.token}
|
||||||
|
onChange={(e) => setFormData({ ...formData, token: e.target.value })}
|
||||||
|
className="input w-full pr-10"
|
||||||
|
placeholder="gitea_api_token"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-100 rounded">
|
||||||
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Owner (user/org)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.owner}
|
||||||
|
onChange={(e) => setFormData({ ...formData, owner: e.target.value })}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="myorg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Repository</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.repo}
|
||||||
|
onChange={(e) => setFormData({ ...formData, repo: e.target.value })}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="kis-endpoints"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Prod Branch</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.prod_branch}
|
||||||
|
onChange={(e) => setFormData({ ...formData, prod_branch: e.target.value })}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="main"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button onClick={() => saveMutation.mutate(formData)} disabled={saveMutation.isPending} className="btn btn-primary">
|
||||||
|
{saveMutation.isPending ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => testMutation.mutate()} disabled={testMutation.isPending} className="btn btn-secondary">
|
||||||
|
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sync status */}
|
||||||
|
{settings?.enabled && (
|
||||||
|
<div className="border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-gray-900">Sync Status</h4>
|
||||||
|
{syncStatus && (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{syncStatus.synced}/{syncStatus.total} endpoints synced
|
||||||
|
{syncStatus.unsynced > 0 && <span className="text-orange-600 ml-1">({syncStatus.unsynced} unsynced)</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => syncMutation.mutate()} disabled={syncMutation.isPending} className="btn btn-secondary text-sm">
|
||||||
|
{syncMutation.isPending ? 'Syncing...' : 'Sync All'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Branches */}
|
||||||
|
{settings?.enabled && branches && (
|
||||||
|
<div className="border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h4 className="font-medium text-gray-900">Branches ({branches.length})</h4>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const name = prompt('Branch name:');
|
||||||
|
if (name) createBranchMutation.mutate(name);
|
||||||
|
}}
|
||||||
|
className="btn btn-secondary text-sm flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Plus size={14} /> New Branch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{branches.map((b: any) => (
|
||||||
|
<div key={b.name} className="flex items-center justify-between text-sm border border-gray-100 rounded-lg p-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GitBranch size={14} className="text-gray-500" />
|
||||||
|
<span className="font-mono font-medium">{b.name}</span>
|
||||||
|
{b.name === (settings?.prod_branch || 'main') && (
|
||||||
|
<span className="text-[10px] bg-green-100 text-green-700 px-1.5 py-0.5 rounded">prod</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-400 font-mono">{b.commit?.id?.slice(0, 7)}</span>
|
||||||
|
{b.name !== (settings?.prod_branch || 'main') && (
|
||||||
|
<button
|
||||||
|
onClick={() => { if (confirm(`Delete branch ${b.name}?`)) deleteBranchMutation.mutate(b.name); }}
|
||||||
|
className="p-1 hover:bg-red-50 rounded text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { AuthResponse, User, Endpoint, Folder, ApiKey, Database, QueryTestResult, ImportPreviewResponse, EndpointVersion } from '@/types';
|
import { AuthResponse, User, Endpoint, Folder, ApiKey, Database, QueryTestResult, ImportPreviewResponse, EndpointVersion, GiteaSettings, GiteaBranch, GiteaCommit, GiteaEndpointInfo } from '@/types';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
@@ -164,6 +164,23 @@ export const versionsApi = {
|
|||||||
api.get<EndpointVersion>(`/endpoints/${endpointId}/draft`),
|
api.get<EndpointVersion>(`/endpoints/${endpointId}/draft`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Gitea API
|
||||||
|
export const giteaApi = {
|
||||||
|
getSettings: () => api.get<GiteaSettings>('/gitea/settings'),
|
||||||
|
updateSettings: (data: Partial<GiteaSettings>) => api.put('/gitea/settings', data),
|
||||||
|
testConnection: () => api.post<{ success: boolean; message: string }>('/gitea/test'),
|
||||||
|
syncAll: () => api.post<{ synced: number; errors: string[] }>('/gitea/sync-all'),
|
||||||
|
getSyncStatus: () => api.get<{ total: number; synced: number; unsynced: number }>('/gitea/sync-status'),
|
||||||
|
listBranches: () => api.get<GiteaBranch[]>('/gitea/branches'),
|
||||||
|
createBranch: (name: string, from?: string) => api.post('/gitea/branches', { name, from }),
|
||||||
|
deleteBranch: (name: string) => api.delete(`/gitea/branches/${encodeURIComponent(name)}`),
|
||||||
|
compareBranches: (base: string, head: string) => api.get('/gitea/compare', { params: { base, head } }),
|
||||||
|
getCommitHistory: (path?: string, branch?: string) => api.get<GiteaCommit[]>('/gitea/commits', { params: { path, branch } }),
|
||||||
|
getEndpointInfo: (endpointId: string) => api.get<GiteaEndpointInfo>(`/gitea/endpoints/${endpointId}/info`),
|
||||||
|
createPR: (data: { title: string; head: string; base?: string; body?: string }) => api.post('/gitea/pulls', data),
|
||||||
|
mergePR: (index: number) => api.post(`/gitea/pulls/${index}/merge`),
|
||||||
|
};
|
||||||
|
|
||||||
// Folders API
|
// Folders API
|
||||||
export const foldersApi = {
|
export const foldersApi = {
|
||||||
getAll: () =>
|
getAll: () =>
|
||||||
|
|||||||
@@ -134,6 +134,38 @@ export interface QueryTestResult {
|
|||||||
processedQuery?: string;
|
processedQuery?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GiteaSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
prod_branch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaBranch {
|
||||||
|
name: string;
|
||||||
|
commit: { id: string; message: string; timestamp: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaCommit {
|
||||||
|
sha: string;
|
||||||
|
message: string;
|
||||||
|
author: { name: string; email: string; date: string };
|
||||||
|
html_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaEndpointInfo {
|
||||||
|
enabled: boolean;
|
||||||
|
url?: string;
|
||||||
|
owner?: string;
|
||||||
|
repo?: string;
|
||||||
|
file_url?: string;
|
||||||
|
repo_path?: string;
|
||||||
|
last_commit_sha?: string;
|
||||||
|
commit_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type VersionStatus = 'draft' | 'published' | 'archived';
|
export type VersionStatus = 'draft' | 'published' | 'archived';
|
||||||
|
|
||||||
export interface EndpointVersion {
|
export interface EndpointVersion {
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
"start:prod": "cd backend && pm2 start dist/server.js --name kis-api-builder-backend --env production",
|
"start:prod": "cd backend && pm2 start dist/server.js --name kis-api-builder-backend --env production",
|
||||||
"reload:prod": "npm run build && cd backend && pm2 reload kis-api-builder-backend",
|
"reload:prod": "npm run build && cd backend && pm2 reload kis-api-builder-backend",
|
||||||
"stop:prod": "cd backend && pm2 stop kis-api-builder-backend",
|
"stop:prod": "cd backend && pm2 stop kis-api-builder-backend",
|
||||||
"delete:prod": "cd backend && pm2 delete kis-api-builder-backend"
|
"delete:prod": "cd backend && pm2 delete kis-api-builder-backend",
|
||||||
|
"lint": "cd backend && npx eslint src/ --ext ts && cd ../frontend && npx eslint src/ --ext ts,tsx",
|
||||||
|
"lint:fix": "cd backend && npx eslint src/ --ext ts --fix && cd ../frontend && npx eslint src/ --ext ts,tsx --fix"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^8.2.2"
|
"concurrently": "^8.2.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user