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:
2026-05-23 22:45:45 +03:00
parent 801d0cce5f
commit 8632e651bf
25 changed files with 2767 additions and 28 deletions

21
backend/.eslintrc.cjs Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -42,6 +42,9 @@
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.6",
"@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",
"nodemon": "^3.0.2",
"ts-node": "^10.9.2",

View File

@@ -1,5 +1,4 @@
import swaggerJsdoc from 'swagger-jsdoc';
import { config } from './environment';
const options: swaggerJsdoc.Options = {
definition: {

View File

@@ -1,7 +1,6 @@
import { Response } from 'express';
import { AuthRequest } from '../middleware/auth';
import { mainPool } from '../config/database';
import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';
export const getApiKeys = async (req: AuthRequest, res: Response) => {

View File

@@ -1,6 +1,6 @@
import { Request, Response } from 'express';
import bcrypt from 'bcrypt';
import jwt, { SignOptions } from 'jsonwebtoken';
import jwt from 'jsonwebtoken';
import { mainPool } from '../config/database';
import { config } from '../config/environment';

View File

@@ -265,6 +265,7 @@ export const testDatabaseConnection = async (req: AuthRequest, res: Response) =>
const dbType = dbResult.rows[0].type;
if (dbType === 'aql') {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { aqlExecutor } = require('../services/AqlExecutor');
const result = await aqlExecutor.testConnection(id, env);

View File

@@ -179,6 +179,7 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
return res.status(500).json({ error: 'AQL configuration is incomplete' });
}
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { aqlExecutor } = require('../services/AqlExecutor');
result = await aqlExecutor.executeAqlQuery(endpoint.database_id, {
method: aqlMethod,

View File

@@ -1,7 +1,6 @@
import { Response } from 'express';
import { AuthRequest } from '../middleware/auth';
import { mainPool } from '../config/database';
import { v4 as uuidv4 } from 'uuid';
import { ExportedEndpoint, ExportedScriptQuery, ScriptExecutionError, Environment } from '../types';
import { encryptEndpointData, decryptEndpointData } from '../services/endpointCrypto';
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 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 scriptResult = await scriptExecutor.execute(script_language, script_code, {
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 result = await aqlExecutor.executeAqlQuery(database_id, {
method: aql_method,
@@ -512,8 +514,8 @@ export const exportEndpoint = async (req: AuthRequest, res: Response) => {
const encrypted = encryptEndpointData(exportData);
const safeFileName = endpoint.name.replace(/[^a-zA-Z0-9_\-]/g, '_');
const encodedFileName = encodeURIComponent(endpoint.name.replace(/[\/\\:*?"<>|]/g, '_')) + '.kabe';
const safeFileName = endpoint.name.replace(/[^a-zA-Z0-9_-]/g, '_');
const encodedFileName = encodeURIComponent(endpoint.name.replace(/[/\\:*?"<>|]/g, '_')) + '.kabe';
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${safeFileName}.kabe"; filename*=UTF-8''${encodedFileName}`);
res.send(encrypted);

View 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 });
}
};

View 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);

View 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;

View File

@@ -1,6 +1,6 @@
import express, { Express, Request, Response } from 'express';
import cors from 'cors';
import helmet from 'helmet';
// import helmet from 'helmet';
// import rateLimit from 'express-rate-limit';
import swaggerUi from 'swagger-ui-express';
import path from 'path';
@@ -21,6 +21,7 @@ import logsRoutes from './routes/logs';
import sqlInterfaceRoutes from './routes/sqlInterface';
import dynamicRoutes from './routes/dynamic';
import syncRoutes from './routes/sync';
import giteaRoutes from './routes/gitea';
const app: Express = express();
@@ -95,6 +96,7 @@ app.use('/api/users', userRoutes);
app.use('/api/logs', logsRoutes);
app.use('/api/workbench', sqlInterfaceRoutes);
app.use('/api/sync', syncRoutes);
app.use('/api/gitea', giteaRoutes);
// Dynamic API routes (user-created endpoints)
app.use('/api/v1', dynamicRoutes);
@@ -107,16 +109,21 @@ if (config.nodeEnv === 'production') {
app.use(express.static(frontendPath, {
maxAge: '1d',
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');
}
}
}));
// SPA fallback - all non-API routes serve index.html
app.get('*', (req: Request, res: Response) => {
if (!req.path.startsWith('/api/') && !req.path.startsWith('/api-docs') && req.path !== '/health') {
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);
} else {
res.status(404).json({ error: 'API route not found' });

View 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();

View File

@@ -185,7 +185,7 @@ export class IsolatedScriptExecutor {
// Capped setTimeout/clearTimeout
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 id = setTimeout(() => {
timerIds.delete(id);

View 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();

View File

@@ -1,6 +1,7 @@
import { mainPool } from '../config/database';
import { EndpointVersion, VersionStatus } from '../types';
import * as crypto from 'crypto';
import { giteaService } from './GiteaService';
const SNAPSHOT_FIELDS = [
'name', 'description', 'method', 'path', 'database_id', 'sql_query',
@@ -81,6 +82,7 @@ class VersionService {
updateParams
);
this.syncToGitea(endpointId, version, status).catch(() => {});
return version;
}
@@ -140,10 +142,11 @@ class VersionService {
[nextVersion, version.id, endpointId]
);
this.syncToGitea(endpointId, version, 'draft').catch(() => {});
return version;
}
async publishVersion(versionId: string, userId: string): Promise<void> {
async publishVersion(versionId: string, _userId: string): Promise<void> {
const vResult = await mainPool.query(
'SELECT * FROM endpoint_versions WHERE id = $1',
[versionId]
@@ -188,6 +191,8 @@ class VersionService {
versionId, version.endpoint_id
]
);
this.syncToGitea(version.endpoint_id, version, 'published').catch(() => {});
}
async rollbackToVersion(
@@ -301,6 +306,46 @@ class VersionService {
);
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();

View File

@@ -138,6 +138,39 @@ export interface IsolatedExecutionResult {
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 {
logs: LogEntry[];
queries: QueryExecution[];

44
docker-compose.gitea.yml Normal file
View 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
View 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',
},
};

View File

@@ -1,9 +1,9 @@
import { useState, useEffect, useMemo } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
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 { 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 SqlEditor from '@/components/SqlEditor';
import CodeEditor from '@/components/CodeEditor';
@@ -105,7 +105,7 @@ export default function EndpointEditor() {
try {
const saved = localStorage.getItem(storageKey);
if (saved) return JSON.parse(saved).testParams || {};
} catch {}
} catch { /* ignore corrupt localStorage */ }
}
return {};
});
@@ -134,7 +134,7 @@ export default function EndpointEditor() {
const parsed = JSON.parse(saved);
if (parsed.testResult) setTestResult(parsed.testResult);
}
} catch {}
} catch { /* ignore */ }
}
}, [storageKey]);
@@ -143,7 +143,7 @@ export default function EndpointEditor() {
if (storageKey) {
try {
localStorage.setItem(storageKey, JSON.stringify({ testParams, testResult }));
} catch {}
} catch { /* ignore */ }
}
}, [storageKey, testParams, testResult]);
@@ -242,6 +242,12 @@ export default function EndpointEditor() {
});
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({
mutationFn: () => versionsApi.saveDraft(id!, { ...formData, change_message: undefined }),
onSuccess: () => {
@@ -1099,6 +1105,44 @@ export default function EndpointEditor() {
</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 */}
{testResult && (
<div className="card overflow-hidden">

View File

@@ -1,9 +1,9 @@
import { useState } from 'react';
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 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 CodeEditor from '@/components/CodeEditor';
@@ -216,7 +216,7 @@ function PasswordTab({ currentUser }: { currentUser: any }) {
}
function GlobalSettingsTab() {
const [subTab, setSubTab] = useState<'databases' | 'users'>('databases');
const [subTab, setSubTab] = useState<'databases' | 'users' | 'gitea'>('databases');
return (
<div className="space-y-6">
@@ -244,11 +244,262 @@ function GlobalSettingsTab() {
<Users className="inline mr-2" size={16} />
Пользователи
</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>
</div>
{subTab === 'databases' && <DatabasesSubTab />}
{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>
);
}

View File

@@ -1,5 +1,5 @@
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({
baseURL: '/api',
@@ -164,6 +164,23 @@ export const versionsApi = {
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
export const foldersApi = {
getAll: () =>

View File

@@ -134,6 +134,38 @@ export interface QueryTestResult {
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 interface EndpointVersion {

View File

@@ -16,7 +16,9 @@
"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",
"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": {
"concurrently": "^8.2.2"