Compare commits

..

6 Commits

Author SHA1 Message Date
45047f40aa feat: 标准化响应体 2026-01-11 16:39:59 +08:00
cd50716dc6 feat: generate docs 2026-01-11 12:24:46 +08:00
14aa3436cf fix: 重新生成 docs 2026-01-11 11:59:04 +08:00
a2f31ec5a0 fix: 移除 doc 2026-01-11 11:16:11 +08:00
82da2a1a04 fix: 优化日志 2026-01-08 21:39:52 +08:00
db2b2af0d3 refactor: 重构部署功能 2026-01-08 19:50:58 +08:00
64 changed files with 653 additions and 1411 deletions

View File

@@ -80,6 +80,7 @@ this.routeScanner.registerControllers([
## TC39 装饰器特性 ## TC39 装饰器特性
### 1. 标准语法 ### 1. 标准语法
```typescript ```typescript
// TC39 标准装饰器使用 addInitializer // TC39 标准装饰器使用 addInitializer
@Get('/users') @Get('/users')
@@ -89,6 +90,7 @@ async getUsers(ctx: Context) {
``` ```
### 2. 类型安全 ### 2. 类型安全
```typescript ```typescript
// 完整的 TypeScript 类型检查 // 完整的 TypeScript 类型检查
@Controller('/api') @Controller('/api')
@@ -101,6 +103,7 @@ export class ApiController {
``` ```
### 3. 无外部依赖 ### 3. 无外部依赖
```typescript ```typescript
// 不再需要 reflect-metadata // 不再需要 reflect-metadata
// 使用内置的 WeakMap 存储元数据 // 使用内置的 WeakMap 存储元数据
@@ -136,6 +139,7 @@ export class ApiController {
最终的API路径 = 全局前缀 + 控制器前缀 + 方法路径 最终的API路径 = 全局前缀 + 控制器前缀 + 方法路径
例如: 例如:
- 全局前缀:`/api` - 全局前缀:`/api`
- 控制器前缀:`/user` - 控制器前缀:`/user`
- 方法路径:`/list` - 方法路径:`/list`
@@ -176,56 +180,11 @@ async getUser(ctx: Context) {
## 现有路由 ## 现有路由
项目中已注册的路由:
### ApplicationController
- `GET /api/application/list` - 获取应用列表
- `GET /api/application/detail/:id` - 获取应用详情
### UserController ### UserController
- `GET /api/user/list` - 获取用户列表 - `GET /api/user/list` - 获取用户列表
- `GET /api/user/detail/:id` - 获取用户详情 - `GET /api/user/detail/:id` - 获取用户详情
- `POST /api/user` - 创建用户 - `POST /api/user` - 创建用户
- `PUT /api/user/:id` - 更新用户 - `PUT /api/user/:id` - 更新用户
- `DELETE /api/user/:id` - 删除用户 - `DELETE /api/user/:id` - 删除用户
- `GET /api/user/search` - 搜索用户 - `GET /api/user/search` - 搜索用户
## 与旧版本装饰器的区别
| 特性 | 实验性装饰器 | TC39 标准装饰器 |
|------|-------------|----------------|
| 标准化 | ❌ TypeScript 特有 | ✅ ECMAScript 标准 |
| 依赖 | ❌ 需要 reflect-metadata | ✅ 零依赖 |
| 性能 | ❌ 运行时反射 | ✅ 编译时优化 |
| 类型安全 | ⚠️ 部分支持 | ✅ 完整支持 |
| 未来兼容 | ❌ 可能被废弃 | ✅ 持续演进 |
## 迁移指南
从实验性装饰器迁移到 TC39 标准装饰器:
1. **更新 tsconfig.json**
```json
{
"experimentalDecorators": false,
"emitDecoratorMetadata": false
}
```
2. **移除依赖**
```bash
pnpm remove reflect-metadata
```
3. **代码无需修改**
- 装饰器语法保持不变
- 控制器代码无需修改
- 自动兼容新标准
## 注意事项
1. 需要 TypeScript 5.0+ 支持
2. 需要 Node.js 16+ 运行环境
3. 控制器类需要导出并在路由中间件中注册
4. 控制器方法应该返回数据而不是直接操作 `ctx.body`
5. TC39 装饰器使用 `addInitializer` 进行初始化,性能更优

View File

@@ -27,6 +27,6 @@ async function initializeApp() {
// 启动应用 // 启动应用
initializeApp().catch((error) => { initializeApp().catch((error) => {
console.error('Failed to start application:', error); log.error('APP', 'Failed to start application:', error);
process.exit(1); process.exit(1);
}); });

View File

@@ -1,8 +1,8 @@
import { z } from 'zod'; import { z } from 'zod';
export const listDeploymentsQuerySchema = z.object({ export const listDeploymentsQuerySchema = z.object({
page: z.coerce.number().int().min(1).optional().default(1), page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional().default(10), pageSize: z.coerce.number().int().min(1).max(100).optional(),
projectId: z.coerce.number().int().positive().optional(), projectId: z.coerce.number().int().positive().optional(),
}); });

View File

@@ -20,22 +20,28 @@ export class DeploymentController {
where.projectId = projectId; where.projectId = projectId;
} }
const isPagination = page !== undefined && pageSize !== undefined;
const result = await prisma.deployment.findMany({ const result = await prisma.deployment.findMany({
where, where,
take: pageSize, take: isPagination ? pageSize : undefined,
skip: (page - 1) * pageSize, skip: isPagination ? (page! - 1) * pageSize! : 0,
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',
}, },
}); });
const total = await prisma.deployment.count({ where }); const total = await prisma.deployment.count({ where });
return { if (isPagination) {
data: result, return {
page, list: result,
pageSize, page,
total, pageSize,
}; total,
};
}
return result;
} }
@Post('') @Post('')

View File

@@ -1,10 +1,13 @@
import type { Context } from 'koa'; import type { Context } from 'koa';
import { Controller, Get } from '../../decorators/route.ts'; import { Controller, Get } from '../../decorators/route.ts';
import { gitea } from '../../libs/gitea.ts'; import { gitea } from '../../libs/gitea.ts';
import { log } from '../../libs/logger.ts';
import { prisma } from '../../libs/prisma.ts'; import { prisma } from '../../libs/prisma.ts';
import { BusinessError } from '../../middlewares/exception.ts'; import { BusinessError } from '../../middlewares/exception.ts';
import { getBranchesQuerySchema, getCommitsQuerySchema } from './dto.ts'; import { getBranchesQuerySchema, getCommitsQuerySchema } from './dto.ts';
const TAG = 'Git';
@Controller('/git') @Controller('/git')
export class GitController { export class GitController {
@Get('/commits') @Get('/commits')
@@ -30,7 +33,7 @@ export class GitController {
// Get access token from session // Get access token from session
const accessToken = ctx.session?.gitea?.access_token; const accessToken = ctx.session?.gitea?.access_token;
console.log('Access token present:', !!accessToken); log.debug(TAG, 'Access token present: %s', !!accessToken);
if (!accessToken) { if (!accessToken) {
throw new BusinessError( throw new BusinessError(
@@ -44,7 +47,7 @@ export class GitController {
const commits = await gitea.getCommits(owner, repo, accessToken, branch); const commits = await gitea.getCommits(owner, repo, accessToken, branch);
return commits; return commits;
} catch (error) { } catch (error) {
console.error('Failed to fetch commits:', error); log.error(TAG, 'Failed to fetch commits:', error);
throw new BusinessError('Failed to fetch commits from Gitea', 1005, 500); throw new BusinessError('Failed to fetch commits from Gitea', 1005, 500);
} }
} }
@@ -80,7 +83,7 @@ export class GitController {
const branches = await gitea.getBranches(owner, repo, accessToken); const branches = await gitea.getBranches(owner, repo, accessToken);
return branches; return branches;
} catch (error) { } catch (error) {
console.error('Failed to fetch branches:', error); log.error(TAG, 'Failed to fetch branches:', error);
throw new BusinessError('Failed to fetch branches from Gitea', 1006, 500); throw new BusinessError('Failed to fetch branches from Gitea', 1006, 500);
} }
} }

View File

@@ -54,7 +54,7 @@ export class PipelineController {
const templates = await getAvailableTemplates(); const templates = await getAvailableTemplates();
return templates; return templates;
} catch (error) { } catch (error) {
console.error('Failed to get templates:', error); log.error('pipeline', 'Failed to get templates:', error);
throw new BusinessError('获取模板失败', 3002, 500); throw new BusinessError('获取模板失败', 3002, 500);
} }
} }
@@ -154,7 +154,7 @@ export class PipelineController {
log.info('pipeline', 'Created pipeline from template: %s', pipeline.name); log.info('pipeline', 'Created pipeline from template: %s', pipeline.name);
return pipeline; return pipeline;
} catch (error) { } catch (error) {
console.error('Failed to create pipeline from template:', error); log.error('pipeline', 'Failed to create pipeline from template:', error);
if (error instanceof BusinessError) { if (error instanceof BusinessError) {
throw error; throw error;
} }

View File

@@ -66,19 +66,8 @@ export const updateProjectSchema = z.object({
*/ */
export const listProjectQuerySchema = z export const listProjectQuerySchema = z
.object({ .object({
page: z.coerce page: z.coerce.number().int().min(1).optional(),
.number() pageSize: z.coerce.number().int().min(1).max(100).optional(),
.int()
.min(1, { message: '页码必须大于0' })
.optional()
.default(1),
limit: z.coerce
.number()
.int()
.min(1, { message: '每页数量必须大于0' })
.max(100, { message: '每页数量不能超过100' })
.optional()
.default(10),
name: z.string().optional(), name: z.string().optional(),
}) })
.optional(); .optional();

View File

@@ -29,27 +29,30 @@ export class ProjectController {
}; };
} }
const isPagination = query?.page !== undefined && query?.pageSize !== undefined;
const [total, projects] = await Promise.all([ const [total, projects] = await Promise.all([
prisma.project.count({ where: whereCondition }), prisma.project.count({ where: whereCondition }),
prisma.project.findMany({ prisma.project.findMany({
where: whereCondition, where: whereCondition,
skip: query ? (query.page - 1) * query.limit : 0, skip: isPagination ? (query.page! - 1) * query.pageSize! : 0,
take: query?.limit, take: isPagination ? query.pageSize : undefined,
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',
}, },
}), }),
]); ]);
return { if (isPagination) {
data: projects, return {
pagination: { list: projects,
page: query?.page || 1, page: query.page,
limit: query?.limit || 10, pageSize: query.pageSize,
total, total,
totalPages: Math.ceil(total / (query?.limit || 10)), };
}, }
};
return projects;
} }
// GET /api/projects/:id - 获取单个项目 // GET /api/projects/:id - 获取单个项目
@@ -68,27 +71,21 @@ export class ProjectController {
throw new BusinessError('项目不存在', 1002, 404); throw new BusinessError('项目不存在', 1002, 404);
} }
// 获取工作目录状态信息 // 获取工作目录状态信息(不包含目录大小)
let workspaceStatus = null; let workspaceStatus = null;
if (project.projectDir) { if (project.projectDir) {
try { try {
const status = await GitManager.checkWorkspaceStatus( const status = await GitManager.checkWorkspaceStatus(
project.projectDir, project.projectDir,
); );
let size = 0;
let gitInfo = null; let gitInfo = null;
if (status.exists && !status.isEmpty) {
size = await GitManager.getDirectorySize(project.projectDir);
}
if (status.hasGit) { if (status.hasGit) {
gitInfo = await GitManager.getGitInfo(project.projectDir); gitInfo = await GitManager.getGitInfo(project.projectDir);
} }
workspaceStatus = { workspaceStatus = {
...status, ...status,
size,
gitInfo, gitInfo,
}; };
} catch (error) { } catch (error) {

View File

@@ -84,15 +84,13 @@ export const listStepsQuerySchema = z
.number() .number()
.int() .int()
.min(1, { message: '页码必须大于0' }) .min(1, { message: '页码必须大于0' })
.optional() .optional(),
.default(1), pageSize: z.coerce
limit: z.coerce
.number() .number()
.int() .int()
.min(1, { message: '每页数量必须大于0' }) .min(1, { message: '每页数量必须大于0' })
.max(100, { message: '每页数量不能超过100' }) .max(100, { message: '每页数量不能超过100' })
.optional() .optional(),
.default(10),
}) })
.optional(); .optional();

View File

@@ -26,27 +26,30 @@ export class StepController {
whereCondition.pipelineId = query.pipelineId; whereCondition.pipelineId = query.pipelineId;
} }
const isPagination = query?.page !== undefined && query?.pageSize !== undefined;
const [total, steps] = await Promise.all([ const [total, steps] = await Promise.all([
prisma.step.count({ where: whereCondition }), prisma.step.count({ where: whereCondition }),
prisma.step.findMany({ prisma.step.findMany({
where: whereCondition, where: whereCondition,
skip: query ? (query.page - 1) * query.limit : 0, skip: isPagination ? (query.page! - 1) * query.pageSize! : 0,
take: query?.limit, take: isPagination ? query.pageSize : undefined,
orderBy: { orderBy: {
order: 'asc', order: 'asc',
}, },
}), }),
]); ]);
return { if (isPagination) {
data: steps, return {
pagination: { list: steps,
page: query?.page || 1, page: query.page,
limit: query?.limit || 10, pageSize: query.pageSize,
total, total,
totalPages: Math.ceil(total / (query?.limit || 10)), };
}, }
};
return steps;
} }
// GET /api/steps/:id - 获取单个步骤 // GET /api/steps/:id - 获取单个步骤

View File

@@ -118,11 +118,6 @@ export class UserController {
results = results.filter((user) => user.status === status); results = results.filter((user) => user.status === status);
} }
return { return results;
keyword,
status,
total: results.length,
results,
};
} }
} }

View File

@@ -1,6 +1,8 @@
import { PipelineRunner } from '../runners/index.ts'; import { PipelineRunner } from '../runners/index.ts';
import { prisma } from './prisma.ts'; import { prisma } from './prisma.ts';
import { log } from '../libs/logger.ts';
const TAG = 'Queue';
// 存储正在运行的部署任务 // 存储正在运行的部署任务
const runningDeployments = new Set<number>(); const runningDeployments = new Set<number>();
@@ -40,14 +42,14 @@ export class ExecutionQueue {
* 初始化执行队列,包括恢复未完成的任务 * 初始化执行队列,包括恢复未完成的任务
*/ */
public async initialize(): Promise<void> { public async initialize(): Promise<void> {
console.log('Initializing execution queue...'); log.info(TAG, 'Initializing execution queue...');
// 恢复未完成的任务 // 恢复未完成的任务
await this.recoverPendingDeployments(); await this.recoverPendingDeployments();
// 启动定时轮询 // 启动定时轮询
this.startPolling(); this.startPolling();
console.log('Execution queue initialized'); log.info(TAG, 'Execution queue initialized');
} }
/** /**
@@ -55,7 +57,7 @@ export class ExecutionQueue {
*/ */
private async recoverPendingDeployments(): Promise<void> { private async recoverPendingDeployments(): Promise<void> {
try { try {
console.log('Recovering pending deployments from database...'); log.info(TAG, 'Recovering pending deployments from database...');
// 查询数据库中状态为pending的部署任务 // 查询数据库中状态为pending的部署任务
const pendingDeployments = await prisma.deployment.findMany({ const pendingDeployments = await prisma.deployment.findMany({
@@ -69,16 +71,16 @@ export class ExecutionQueue {
}, },
}); });
console.log(`Found ${pendingDeployments.length} pending deployments`); log.info(TAG, `Found ${pendingDeployments.length} pending deployments`);
// 将这些任务添加到执行队列中 // 将这些任务添加到执行队列中
for (const deployment of pendingDeployments) { for (const deployment of pendingDeployments) {
await this.addTask(deployment.id, deployment.pipelineId); await this.addTask(deployment.id, deployment.pipelineId);
} }
console.log('Pending deployments recovery completed'); log.info(TAG, 'Pending deployments recovery completed');
} catch (error) { } catch (error) {
console.error('Failed to recover pending deployments:', error); log.error(TAG, 'Failed to recover pending deployments:', error);
} }
} }
@@ -87,12 +89,12 @@ export class ExecutionQueue {
*/ */
private startPolling(): void { private startPolling(): void {
if (this.isPolling) { if (this.isPolling) {
console.log('Polling is already running'); log.info(TAG, 'Polling is already running');
return; return;
} }
this.isPolling = true; this.isPolling = true;
console.log(`Starting polling with interval ${POLLING_INTERVAL}ms`); log.info(TAG, `Starting polling with interval ${POLLING_INTERVAL}ms`);
// 立即执行一次检查 // 立即执行一次检查
this.checkPendingDeployments(); this.checkPendingDeployments();
@@ -111,7 +113,7 @@ export class ExecutionQueue {
clearInterval(pollingTimer); clearInterval(pollingTimer);
pollingTimer = null; pollingTimer = null;
this.isPolling = false; this.isPolling = false;
console.log('Polling stopped'); log.info(TAG, 'Polling stopped');
} }
} }
@@ -120,7 +122,7 @@ export class ExecutionQueue {
*/ */
private async checkPendingDeployments(): Promise<void> { private async checkPendingDeployments(): Promise<void> {
try { try {
console.log('Checking for pending deployments in database...'); log.info(TAG, 'Checking for pending deployments in database...');
// 查询数据库中状态为pending的部署任务 // 查询数据库中状态为pending的部署任务
const pendingDeployments = await prisma.deployment.findMany({ const pendingDeployments = await prisma.deployment.findMany({
@@ -134,7 +136,8 @@ export class ExecutionQueue {
}, },
}); });
console.log( log.info(
TAG,
`Found ${pendingDeployments.length} pending deployments in polling`, `Found ${pendingDeployments.length} pending deployments in polling`,
); );
@@ -142,14 +145,15 @@ export class ExecutionQueue {
for (const deployment of pendingDeployments) { for (const deployment of pendingDeployments) {
// 检查是否已经在运行队列中 // 检查是否已经在运行队列中
if (!runningDeployments.has(deployment.id)) { if (!runningDeployments.has(deployment.id)) {
console.log( log.info(
TAG,
`Adding deployment ${deployment.id} to queue from polling`, `Adding deployment ${deployment.id} to queue from polling`,
); );
await this.addTask(deployment.id, deployment.pipelineId); await this.addTask(deployment.id, deployment.pipelineId);
} }
} }
} catch (error) { } catch (error) {
console.error('Failed to check pending deployments:', error); log.error(TAG, 'Failed to check pending deployments:', error);
} }
} }
@@ -164,7 +168,7 @@ export class ExecutionQueue {
): Promise<void> { ): Promise<void> {
// 检查是否已经在运行队列中 // 检查是否已经在运行队列中
if (runningDeployments.has(deploymentId)) { if (runningDeployments.has(deploymentId)) {
console.log(`Deployment ${deploymentId} is already queued or running`); log.info(TAG, `Deployment ${deploymentId} is already queued or running`);
return; return;
} }
@@ -194,7 +198,7 @@ export class ExecutionQueue {
// 执行流水线 // 执行流水线
await this.executePipeline(task.deploymentId, task.pipelineId); await this.executePipeline(task.deploymentId, task.pipelineId);
} catch (error) { } catch (error) {
console.error('执行流水线失败:', error); log.error(TAG, '执行流水线失败:', error);
// 这里可以添加更多的错误处理逻辑 // 这里可以添加更多的错误处理逻辑
} finally { } finally {
// 从运行队列中移除 // 从运行队列中移除
@@ -245,7 +249,7 @@ export class ExecutionQueue {
); );
await runner.run(pipelineId); await runner.run(pipelineId);
} catch (error) { } catch (error) {
console.error('执行流水线失败:', error); log.error(TAG, '执行流水线失败:', error);
// 错误处理可以在这里添加,比如更新部署状态为失败 // 错误处理可以在这里添加,比如更新部署状态为失败
throw error; throw error;
} }

View File

@@ -1,3 +1,7 @@
import { log } from './logger.ts';
const TAG = 'Gitea';
interface TokenResponse { interface TokenResponse {
access_token: string; access_token: string;
token_type: string; token_type: string;
@@ -43,7 +47,7 @@ class Gitea {
async getToken(code: string) { async getToken(code: string) {
const { giteaUrl, clientId, clientSecret, redirectUri } = this.config; const { giteaUrl, clientId, clientSecret, redirectUri } = this.config;
console.log('this.config', this.config); log.debug(TAG, 'Gitea token request started');
const response = await fetch(`${giteaUrl}/login/oauth/access_token`, { const response = await fetch(`${giteaUrl}/login/oauth/access_token`, {
method: 'POST', method: 'POST',
headers: this.getHeaders(), headers: this.getHeaders(),
@@ -56,7 +60,13 @@ class Gitea {
}), }),
}); });
if (!response.ok) { if (!response.ok) {
console.log(await response.json()); const payload = await response.json().catch(() => null as unknown);
log.error(
TAG,
'Gitea token request failed: status=%d payload=%o',
response.status,
payload,
);
throw new Error(`Fetch failed: ${response.status}`); throw new Error(`Fetch failed: ${response.status}`);
} }
return (await response.json()) as TokenResponse; return (await response.json()) as TokenResponse;

View File

@@ -1,4 +1,7 @@
import { prisma } from './prisma.ts'; import { prisma } from './prisma.ts';
import { log } from './logger.ts';
const TAG = 'PipelineTemplate';
// 默认流水线模板 // 默认流水线模板
export interface PipelineTemplate { export interface PipelineTemplate {
@@ -52,7 +55,7 @@ export const DEFAULT_PIPELINE_TEMPLATES: PipelineTemplate[] = [
* 初始化系统默认流水线模板 * 初始化系统默认流水线模板
*/ */
export async function initializePipelineTemplates(): Promise<void> { export async function initializePipelineTemplates(): Promise<void> {
console.log('Initializing pipeline templates...'); log.info(TAG, 'Initializing pipeline templates...');
try { try {
// 检查是否已经存在模板流水线 // 检查是否已经存在模板流水线
@@ -67,7 +70,7 @@ export async function initializePipelineTemplates(): Promise<void> {
// 如果没有现有的模板,则创建默认模板 // 如果没有现有的模板,则创建默认模板
if (existingTemplates.length === 0) { if (existingTemplates.length === 0) {
console.log('Creating default pipeline templates...'); log.info(TAG, 'Creating default pipeline templates...');
for (const template of DEFAULT_PIPELINE_TEMPLATES) { for (const template of DEFAULT_PIPELINE_TEMPLATES) {
// 创建模板流水线使用负数ID表示模板 // 创建模板流水线使用负数ID表示模板
@@ -97,15 +100,18 @@ export async function initializePipelineTemplates(): Promise<void> {
}); });
} }
console.log(`Created template: ${template.name}`); log.info(TAG, `Created template: ${template.name}`);
} }
} else { } else {
console.log('Pipeline templates already exist, skipping initialization'); log.info(
TAG,
'Pipeline templates already exist, skipping initialization',
);
} }
console.log('Pipeline templates initialization completed'); log.info(TAG, 'Pipeline templates initialization completed');
} catch (error) { } catch (error) {
console.error('Failed to initialize pipeline templates:', error); log.error(TAG, 'Failed to initialize pipeline templates:', error);
throw error; throw error;
} }
} }
@@ -136,7 +142,7 @@ export async function getAvailableTemplates(): Promise<
description: template.description || '', description: template.description || '',
})); }));
} catch (error) { } catch (error) {
console.error('Failed to get pipeline templates:', error); log.error(TAG, 'Failed to get pipeline templates:', error);
throw error; throw error;
} }
} }
@@ -205,12 +211,13 @@ export async function createPipelineFromTemplate(
}); });
} }
console.log( log.info(
TAG,
`Created pipeline from template ${templateId}: ${newPipeline.name}`, `Created pipeline from template ${templateId}: ${newPipeline.name}`,
); );
return newPipeline.id; return newPipeline.id;
} catch (error) { } catch (error) {
console.error('Failed to create pipeline from template:', error); log.error(TAG, 'Failed to create pipeline from template:', error);
throw error; throw error;
} }
} }

View File

@@ -6,6 +6,9 @@ import {
type RouteMetadata, type RouteMetadata,
} from '../decorators/route.ts'; } from '../decorators/route.ts';
import { createSuccessResponse } from '../middlewares/exception.ts'; import { createSuccessResponse } from '../middlewares/exception.ts';
import { log } from './logger.ts';
const TAG = 'RouteScanner';
/** /**
* 控制器类型 * 控制器类型
@@ -79,7 +82,7 @@ export class RouteScanner {
this.router.patch(fullPath, handler); this.router.patch(fullPath, handler);
break; break;
default: default:
console.warn(`未支持的HTTP方法: ${route.method}`); log.info(TAG, `未支持的HTTP方法: ${route.method}`);
} }
}); });
} }

View File

@@ -4,11 +4,11 @@ import type { Middleware } from './types.ts';
export class Session implements Middleware { export class Session implements Middleware {
apply(app: Koa): void { apply(app: Koa): void {
app.keys = ['foka-ci']; app.keys = ['mini-ci'];
app.use( app.use(
session( session(
{ {
key: 'foka.sid', key: 'mini-ci.sid',
maxAge: 86400000, maxAge: 86400000,
autoCommit: true /** (boolean) automatically commit headers (default true) */, autoCommit: true /** (boolean) automatically commit headers (default true) */,
overwrite: true /** (boolean) can overwrite or not (default true) */, overwrite: true /** (boolean) can overwrite or not (default true) */,

Binary file not shown.

View File

@@ -66,11 +66,14 @@ export class PipelineRunner {
// 依次执行每个步骤 // 依次执行每个步骤
for (const [index, step] of pipeline.steps.entries()) { for (const [index, step] of pipeline.steps.entries()) {
const progress = `[${index + 1}/${pipeline.steps.length}]`;
// 准备环境变量 // 准备环境变量
const envVars = this.prepareEnvironmentVariables(pipeline, deployment); const envVars = this.prepareEnvironmentVariables(pipeline, deployment);
// 记录开始执行步骤的日志 // 记录开始执行步骤的日志
const startLog = `[${new Date().toISOString()}] 开始执行步骤 ${index + 1}/${pipeline.steps.length}: ${step.name}\n`; const startLog = this.addTimestamp(
`${progress} 开始执行: ${step.name}`,
);
logs += startLog; logs += startLog;
// 实时更新日志 // 实时更新日志
@@ -81,10 +84,10 @@ export class PipelineRunner {
// 执行步骤 // 执行步骤
const stepLog = await this.executeStep(step, envVars); const stepLog = await this.executeStep(step, envVars);
logs += `${stepLog}\n`; logs += stepLog;
// 记录步骤执行完成的日志 // 记录步骤执行完成的日志
const endLog = `[${new Date().toISOString()}] 步骤 "${step.name}" 执行完成\n`; const endLog = this.addTimestamp(`${progress} 执行完成: ${step.name}`);
logs += endLog; logs += endLog;
// 实时更新日志 // 实时更新日志
@@ -93,9 +96,16 @@ export class PipelineRunner {
data: { buildLog: logs }, data: { buildLog: logs },
}); });
} }
await prisma.deployment.update({
where: { id: this.deploymentId },
data: {
buildLog: logs,
status: 'success',
finishedAt: new Date(),
},
});
} catch (error) { } catch (error) {
hasError = true; const errorMsg = this.addTimestamp(`${(error as Error).message}`);
const errorMsg = `[${new Date().toISOString()}] Error: ${(error as Error).message}\n`;
logs += errorMsg; logs += errorMsg;
log.error( log.error(
@@ -116,18 +126,6 @@ export class PipelineRunner {
throw error; throw error;
} }
// 更新最终状态
if (!hasError) {
await prisma.deployment.update({
where: { id: this.deploymentId },
data: {
buildLog: logs,
status: 'success',
finishedAt: new Date(),
},
});
}
} }
/** /**
@@ -141,21 +139,20 @@ export class PipelineRunner {
branch: string, branch: string,
): Promise<string> { ): Promise<string> {
let logs = ''; let logs = '';
const timestamp = new Date().toISOString();
try { try {
logs += `[${timestamp}] 检查工作目录状态: ${this.projectDir}\n`; logs += this.addTimestamp(`检查工作目录状态: ${this.projectDir}`);
// 检查工作目录状态 // 检查工作目录状态
const status = await GitManager.checkWorkspaceStatus(this.projectDir); const status = await GitManager.checkWorkspaceStatus(this.projectDir);
logs += `[${new Date().toISOString()}] 工作目录状态: ${status.status}\n`; logs += this.addTimestamp(`工作目录状态: ${status.status}`);
if ( if (
status.status === WorkspaceDirStatus.NOT_CREATED || status.status === WorkspaceDirStatus.NOT_CREATED ||
status.status === WorkspaceDirStatus.EMPTY status.status === WorkspaceDirStatus.EMPTY
) { ) {
// 目录不存在或为空,需要克隆 // 目录不存在或为空,需要克隆
logs += `[${new Date().toISOString()}] 工作目录不存在或为空,开始克隆仓库\n`; logs += this.addTimestamp('工作目录不存在或为空,开始克隆仓库');
// 确保父目录存在 // 确保父目录存在
await GitManager.ensureDirectory(this.projectDir); await GitManager.ensureDirectory(this.projectDir);
@@ -168,7 +165,7 @@ export class PipelineRunner {
// TODO: 添加 token 支持 // TODO: 添加 token 支持
); );
logs += `[${new Date().toISOString()}] 仓库克隆成功\n`; logs += this.addTimestamp('仓库克隆成功');
} else if (status.status === WorkspaceDirStatus.NO_GIT) { } else if (status.status === WorkspaceDirStatus.NO_GIT) {
// 目录存在但不是 Git 仓库 // 目录存在但不是 Git 仓库
throw new Error( throw new Error(
@@ -176,14 +173,16 @@ export class PipelineRunner {
); );
} else if (status.status === WorkspaceDirStatus.READY) { } else if (status.status === WorkspaceDirStatus.READY) {
// 已存在 Git 仓库,更新代码 // 已存在 Git 仓库,更新代码
logs += `[${new Date().toISOString()}] 工作目录已存在 Git 仓库,开始更新代码\n`; logs += this.addTimestamp('工作目录已存在 Git 仓库,开始更新代码');
await GitManager.updateRepository(this.projectDir, branch); await GitManager.updateRepository(this.projectDir, branch);
logs += `[${new Date().toISOString()}] 代码更新成功\n`; logs += this.addTimestamp('代码更新成功');
} }
return logs; return logs;
} catch (error) { } catch (error) {
const errorLog = `[${new Date().toISOString()}] 准备工作目录失败: ${(error as Error).message}\n`; const errorLog = this.addTimestamp(
`准备工作目录失败: ${(error as Error).message}`,
);
logs += errorLog; logs += errorLog;
log.error( log.error(
this.TAG, this.TAG,
@@ -221,7 +220,7 @@ export class PipelineRunner {
const userEnvVars = JSON.parse(deployment.envVars); const userEnvVars = JSON.parse(deployment.envVars);
Object.assign(envVars, userEnvVars); Object.assign(envVars, userEnvVars);
} catch (error) { } catch (error) {
console.error('解析环境变量失败:', error); log.error(this.TAG, '解析环境变量失败:', error);
} }
} }
@@ -237,28 +236,9 @@ export class PipelineRunner {
* @param isError 是否为错误日志 * @param isError 是否为错误日志
* @returns 带时间戳的日志消息 * @returns 带时间戳的日志消息
*/ */
private addTimestamp(message: string, isError = false): string { private addTimestamp(message: string): string {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
if (isError) { return `[${timestamp}] [ERROR] ${message}\n`;
return `[${timestamp}] [ERROR] ${message}`;
}
return `[${timestamp}] ${message}`;
}
/**
* 为多行日志添加时间戳前缀
* @param content 多行日志内容
* @param isError 是否为错误日志
* @returns 带时间戳的多行日志消息
*/
private addTimestampToLines(content: string, isError = false): string {
if (!content) return '';
return `${content
.split('\n')
.filter((line) => line.trim() !== '')
.map((line) => this.addTimestamp(line, isError))
.join('\n')}\n`;
} }
/** /**
@@ -272,35 +252,21 @@ export class PipelineRunner {
): Promise<string> { ): Promise<string> {
let logs = ''; let logs = '';
try { // 使用zx执行脚本设置项目目录为工作目录和环境变量
// 添加步骤开始执行的时间戳 const script = step.script;
logs += `${this.addTimestamp(`执行脚本: ${step.script}`)}\n`;
// 使用zx执行脚本设置项目目录为工作目录和环境变量 // bash -c 执行脚本,确保环境变量能被正确解析
const script = step.script; const result = await $({
cwd: this.projectDir,
env: { ...process.env, ...envVars },
})`bash -c ${script}`;
// 通过bash -c执行脚本确保环境变量能被正确解析 if (result.stdout) {
const result = await $({ logs += this.addTimestamp(`\n${result.stdout}`);
cwd: this.projectDir, }
env: { ...process.env, ...envVars },
})`bash -c ${script}`;
if (result.stdout) { if (result.stderr) {
// 为stdout中的每一行添加时间戳 logs += this.addTimestamp(`\n${result.stderr}`);
logs += this.addTimestampToLines(result.stdout);
}
if (result.stderr) {
// 为stderr中的每一行添加时间戳和错误标记
logs += this.addTimestampToLines(result.stderr, true);
}
logs += `${this.addTimestamp(`步骤执行完成`)}\n`;
} catch (error) {
const errorMsg = `Error executing step "${step.name}": ${(error as Error).message}`;
logs += `${this.addTimestamp(errorMsg, true)}\n`;
log.error(this.TAG, errorMsg);
throw error;
} }
return logs; return logs;

View File

@@ -7,7 +7,7 @@ import { pluginSvgr } from '@rsbuild/plugin-svgr';
export default defineConfig({ export default defineConfig({
plugins: [pluginReact(), pluginLess(), pluginSvgr()], plugins: [pluginReact(), pluginLess(), pluginSvgr()],
html: { html: {
title: 'Foka CI', title: 'Mini CI',
}, },
source: { source: {
define: { define: {

View File

@@ -2,7 +2,7 @@ import type React from 'react';
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
export function useAsyncEffect( export function useAsyncEffect(
effect: () => Promise<undefined | (() => void)>, effect: () => Promise<any | (() => void)>,
deps: React.DependencyList, deps: React.DependencyList,
) { ) {
const callback = useCallback(effect, [...deps]); const callback = useCallback(effect, [...deps]);

View File

@@ -1,4 +1,4 @@
import Env from '@pages/env';
import Home from '@pages/home'; import Home from '@pages/home';
import Login from '@pages/login'; import Login from '@pages/login';
import ProjectDetail from '@pages/project/detail'; import ProjectDetail from '@pages/project/detail';
@@ -13,7 +13,7 @@ const App = () => {
<Route index element={<Navigate to="project" replace />} /> <Route index element={<Navigate to="project" replace />} />
<Route path="project" element={<ProjectList />} /> <Route path="project" element={<ProjectList />} />
<Route path="project/:id" element={<ProjectDetail />} /> <Route path="project/:id" element={<ProjectDetail />} />
<Route path="env" element={<Env />} />
</Route> </Route>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
</Routes> </Routes>

View File

@@ -1,5 +0,0 @@
function Env() {
return <div>env page</div>;
}
export default Env;

View File

@@ -4,7 +4,7 @@ import {
IconExport, IconExport,
IconMenuFold, IconMenuFold,
IconMenuUnfold, IconMenuUnfold,
IconRobot,
} from '@arco-design/web-react/icon'; } from '@arco-design/web-react/icon';
import Logo from '@assets/images/logo.svg?react'; import Logo from '@assets/images/logo.svg?react';
import { loginService } from '@pages/login/service'; import { loginService } from '@pages/login/service';
@@ -31,7 +31,7 @@ function Home() {
> >
<div className="flex flex-row items-center justify-center h-[56px]"> <div className="flex flex-row items-center justify-center h-[56px]">
<Logo /> <Logo />
{!collapsed && <h2 className="ml-4 text-xl font-medium">Foka CI</h2>} {!collapsed && <h2 className="ml-4 text-xl font-medium">Mini CI</h2>}
</div> </div>
<Menu <Menu
className="flex-1" className="flex-1"
@@ -45,12 +45,7 @@ function Home() {
<span></span> <span></span>
</Link> </Link>
</Menu.Item> </Menu.Item>
<Menu.Item key="1">
<Link to="/env">
<IconRobot fontSize={16} />
</Link>
</Menu.Item>
</Menu> </Menu>
</Layout.Sider> </Layout.Sider>
<Layout> <Layout>

View File

@@ -1,12 +1,12 @@
import { Message, Notification } from '@arco-design/web-react'; import { Message, Notification } from '@arco-design/web-react';
import { net } from '@shared'; import { net } from '../../utils';
import type { NavigateFunction } from 'react-router'; import type { NavigateFunction } from 'react-router';
import { useGlobalStore } from '../../stores/global'; import { useGlobalStore } from '../../stores/global';
import type { AuthLoginResponse, AuthURLResponse } from './types'; import type { AuthURL, User } from './types';
class LoginService { class LoginService {
async getAuthUrl() { async getAuthUrl() {
const { code, data } = await net.request<AuthURLResponse>({ const { code, data } = await net.request<AuthURL>({
method: 'GET', method: 'GET',
url: '/api/auth/url', url: '/api/auth/url',
params: { params: {
@@ -19,7 +19,7 @@ class LoginService {
} }
async login(authCode: string, navigate: NavigateFunction) { async login(authCode: string, navigate: NavigateFunction) {
const { data, code } = await net.request<AuthLoginResponse>({ const { data, code } = await net.request<User>({
method: 'POST', method: 'POST',
url: '/api/auth/login', url: '/api/auth/login',
data: { data: {
@@ -37,7 +37,7 @@ class LoginService {
} }
async logout() { async logout() {
const { code } = await net.request<AuthURLResponse>({ const { code } = await net.request<null>({
method: 'GET', method: 'GET',
url: '/api/auth/logout', url: '/api/auth/logout',
}); });

View File

@@ -1,5 +1,3 @@
import type { APIResponse } from '@shared';
export interface User { export interface User {
id: string; id: string;
username: string; username: string;
@@ -8,8 +6,6 @@ export interface User {
active: boolean; active: boolean;
} }
export type AuthURLResponse = APIResponse<{ export interface AuthURL {
url: string; url: string;
}>; };
export type AuthLoginResponse = APIResponse<User>;

View File

@@ -10,6 +10,7 @@ import {
Menu, Menu,
Message, Message,
Modal, Modal,
Pagination,
Select, Select,
Space, Space,
Switch, Switch,
@@ -94,6 +95,11 @@ function ProjectDetailPage() {
const [pipelineForm] = Form.useForm(); const [pipelineForm] = Form.useForm();
const [deployRecords, setDeployRecords] = useState<Deployment[]>([]); const [deployRecords, setDeployRecords] = useState<Deployment[]>([]);
const [deployModalVisible, setDeployModalVisible] = useState(false); const [deployModalVisible, setDeployModalVisible] = useState(false);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
// 流水线模板相关状态 // 流水线模板相关状态
const [isCreatingFromTemplate, setIsCreatingFromTemplate] = useState(false); const [isCreatingFromTemplate, setIsCreatingFromTemplate] = useState(false);
@@ -153,10 +159,15 @@ function ProjectDetailPage() {
// 获取部署记录 // 获取部署记录
try { try {
const records = await detailService.getDeployments(Number(id)); const res = await detailService.getDeployments(
setDeployRecords(records); Number(id),
if (records.length > 0) { 1,
setSelectedRecordId(records[0].id); pagination.pageSize,
);
setDeployRecords(res.list);
setPagination((prev) => ({ ...prev, total: res.total, current: 1 }));
if (res.list.length > 0) {
setSelectedRecordId(res.list[0].id);
} }
} catch (error) { } catch (error) {
console.error('获取部署记录失败:', error); console.error('获取部署记录失败:', error);
@@ -175,32 +186,40 @@ function ProjectDetailPage() {
}; };
// 定期轮询部署记录以更新状态和日志 // 定期轮询部署记录以更新状态和日志
useAsyncEffect(async () => { useEffect(() => {
const interval = setInterval(async () => { if (!id) return;
if (id) {
try {
const records = await detailService.getDeployments(Number(id));
setDeployRecords(records);
// 如果当前选中的记录正在运行,则更新选中记录 const poll = async () => {
const selectedRecord = records.find( try {
(r: Deployment) => r.id === selectedRecordId, const res = await detailService.getDeployments(
); Number(id),
if ( pagination.current,
selectedRecord && pagination.pageSize,
(selectedRecord.status === 'running' || );
selectedRecord.status === 'pending') setDeployRecords(res.list);
) { setPagination((prev) => ({ ...prev, total: res.total }));
// 保持当前选中状态,但更新数据
} // 如果当前选中的记录正在运行,则更新选中记录
} catch (error) { const selectedRecord = res.list.find(
console.error('轮询部署记录失败:', error); (r: Deployment) => r.id === selectedRecordId,
);
if (
selectedRecord &&
(selectedRecord.status === 'running' ||
selectedRecord.status === 'pending')
) {
// 保持当前选中状态,但更新数据
} }
} catch (error) {
console.error('轮询部署记录失败:', error);
} }
}, 3000); // 每3秒轮询一次 };
poll(); // 立即执行一次
const interval = setInterval(poll, 3000); // 每3秒轮询一次
return () => clearInterval(interval); return () => clearInterval(interval);
}, [id, selectedRecordId]); }, [id, selectedRecordId, pagination.current, pagination.pageSize]);
// 触发部署 // 触发部署
const handleDeploy = () => { const handleDeploy = () => {
@@ -601,8 +620,13 @@ function ProjectDetailPage() {
// 刷新部署记录 // 刷新部署记录
if (id) { if (id) {
const records = await detailService.getDeployments(Number(id)); const res = await detailService.getDeployments(
setDeployRecords(records); Number(id),
pagination.current,
pagination.pageSize,
);
setDeployRecords(res.list);
setPagination((prev) => ({ ...prev, total: res.total }));
} }
} catch (error) { } catch (error) {
console.error('重新执行部署失败:', error); console.error('重新执行部署失败:', error);
@@ -725,24 +749,9 @@ function ProjectDetailPage() {
item={item} item={item}
isSelected={selectedRecordId === item.id} isSelected={selectedRecordId === item.id}
onSelect={setSelectedRecordId} onSelect={setSelectedRecordId}
onRetry={handleRetryDeployment} // 传递重新执行函数
/> />
); );
// 获取选中的流水线
const _selectedPipeline = pipelines.find(
(pipeline) => pipeline.id === selectedPipelineId,
);
// 格式化文件大小
const formatSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / k ** i).toFixed(2)} ${sizes[i]}`;
};
// 获取工作目录状态标签 // 获取工作目录状态标签
const getWorkspaceStatusTag = ( const getWorkspaceStatusTag = (
status: string, status: string,
@@ -784,12 +793,6 @@ function ProjectDetailPage() {
label: '状态', label: '状态',
value: <Tag color={statusInfo.color}>{statusInfo.text}</Tag>, value: <Tag color={statusInfo.color}>{statusInfo.text}</Tag>,
}, },
{
label: '目录大小',
value: workspaceStatus.size
? formatSize(workspaceStatus.size)
: '-',
},
{ {
label: '当前分支', label: '当前分支',
value: workspaceStatus.gitInfo?.branch || '-', value: workspaceStatus.gitInfo?.branch || '-',
@@ -797,7 +800,7 @@ function ProjectDetailPage() {
{ {
label: '最后提交', label: '最后提交',
value: workspaceStatus.gitInfo?.lastCommit ? ( value: workspaceStatus.gitInfo?.lastCommit ? (
<Space direction="vertical" size="mini"> <Space size="small">
<Typography.Text code> <Typography.Text code>
{workspaceStatus.gitInfo.lastCommit} {workspaceStatus.gitInfo.lastCommit}
</Typography.Text> </Typography.Text>
@@ -862,19 +865,33 @@ function ProjectDetailPage() {
</Button> </Button>
</div> </div>
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0 flex flex-col">
{deployRecords.length > 0 ? ( <div className="flex-1 overflow-y-auto">
<List {deployRecords.length > 0 ? (
className="bg-white rounded-lg border" <List
dataSource={deployRecords} className="bg-white rounded-lg border"
render={renderDeployRecordItem} dataSource={deployRecords}
split={true} render={renderDeployRecordItem}
split={true}
/>
) : (
<div className="text-center py-12">
<Empty description="暂无部署记录" />
</div>
)}
</div>
<div className="mt-2 text-right">
<Pagination
total={pagination.total}
current={pagination.current}
pageSize={pagination.pageSize}
size="small"
simple
onChange={(page) =>
setPagination((prev) => ({ ...prev, current: page }))
}
/> />
) : ( </div>
<div className="text-center py-12">
<Empty description="暂无部署记录" />
</div>
)}
</div> </div>
</div> </div>
@@ -1404,14 +1421,7 @@ function ProjectDetailPage() {
style={{ fontFamily: 'Monaco, Consolas, monospace' }} style={{ fontFamily: 'Monaco, Consolas, monospace' }}
/> />
</Form.Item> </Form.Item>
<div className="bg-blue-50 p-3 rounded text-sm">
<Typography.Text type="secondary">
<strong></strong>
<br /> $PROJECT_NAME -
<br /> $BUILD_NUMBER -
<br /> $REGISTRY -
</Typography.Text>
</div>
</Form> </Form>
</Modal> </Modal>
@@ -1422,12 +1432,19 @@ function ProjectDetailPage() {
setDeployModalVisible(false); setDeployModalVisible(false);
// 刷新部署记录 // 刷新部署记录
if (id) { if (id) {
detailService.getDeployments(Number(id)).then((records) => { detailService
setDeployRecords(records); .getDeployments(Number(id), 1, pagination.pageSize)
if (records.length > 0) { .then((res) => {
setSelectedRecordId(records[0].id); setDeployRecords(res.list);
} setPagination((prev) => ({
}); ...prev,
total: res.total,
current: 1,
}));
if (res.list.length > 0) {
setSelectedRecordId(res.list[0].id);
}
});
} }
}} }}
pipelines={pipelines} pipelines={pipelines}

View File

@@ -1,4 +1,4 @@
import { type APIResponse, net } from '@shared'; import { net } from '../../../utils';
import type { import type {
Branch, Branch,
Commit, Commit,
@@ -11,7 +11,7 @@ import type {
class DetailService { class DetailService {
async getProject(id: string) { async getProject(id: string) {
const { data } = await net.request<APIResponse<Project>>({ const { data } = await net.request<Project>({
url: `/api/projects/${id}`, url: `/api/projects/${id}`,
}); });
return data; return data;
@@ -19,28 +19,32 @@ class DetailService {
// 获取项目的所有流水线 // 获取项目的所有流水线
async getPipelines(projectId: number) { async getPipelines(projectId: number) {
const { data } = await net.request<APIResponse<Pipeline[]>>({ const { data } = await net.request<Pipeline[] | { list: Pipeline[] }>({
url: `/api/pipelines?projectId=${projectId}`, url: `/api/pipelines?projectId=${projectId}`,
}); });
return data; return Array.isArray(data) ? data : data.list;
} }
// 获取可用的流水线模板 // 获取可用的流水线模板
async getPipelineTemplates() { async getPipelineTemplates() {
const { data } = await net.request< const { data } = await net.request<
APIResponse<{ id: number; name: string; description: string }[]> | { id: number; name: string; description: string }[]
| { list: { id: number; name: string; description: string }[] }
>({ >({
url: '/api/pipelines/templates', url: '/api/pipelines/templates',
}); });
return data; return Array.isArray(data) ? data : data.list;
} }
// 获取项目的部署记录 async getDeployments(
async getDeployments(projectId: number) { projectId: number,
const { data } = await net.request<any>({ page: number = 1,
url: `/api/deployments?projectId=${projectId}`, pageSize: number = 10,
) {
const { data } = await net.request<DeploymentListResponse>({
url: `/api/deployments?projectId=${projectId}&page=${page}&pageSize=${pageSize}`,
}); });
return data.data; return data;
} }
// 创建流水线 // 创建流水线
@@ -56,7 +60,7 @@ class DetailService {
| 'steps' | 'steps'
>, >,
) { ) {
const { data } = await net.request<APIResponse<Pipeline>>({ const { data } = await net.request<Pipeline>({
url: '/api/pipelines', url: '/api/pipelines',
method: 'POST', method: 'POST',
data: pipeline, data: pipeline,
@@ -71,7 +75,7 @@ class DetailService {
name: string, name: string,
description?: string, description?: string,
) { ) {
const { data } = await net.request<APIResponse<Pipeline>>({ const { data } = await net.request<Pipeline>({
url: '/api/pipelines/from-template', url: '/api/pipelines/from-template',
method: 'POST', method: 'POST',
data: { data: {
@@ -100,7 +104,7 @@ class DetailService {
> >
>, >,
) { ) {
const { data } = await net.request<APIResponse<Pipeline>>({ const { data } = await net.request<Pipeline>({
url: `/api/pipelines/${id}`, url: `/api/pipelines/${id}`,
method: 'PUT', method: 'PUT',
data: pipeline, data: pipeline,
@@ -110,7 +114,7 @@ class DetailService {
// 删除流水线 // 删除流水线
async deletePipeline(id: number) { async deletePipeline(id: number) {
const { data } = await net.request<APIResponse<null>>({ const { data } = await net.request<null>({
url: `/api/pipelines/${id}`, url: `/api/pipelines/${id}`,
method: 'DELETE', method: 'DELETE',
}); });
@@ -119,10 +123,10 @@ class DetailService {
// 获取流水线的所有步骤 // 获取流水线的所有步骤
async getSteps(pipelineId: number) { async getSteps(pipelineId: number) {
const { data } = await net.request<APIResponse<Step[]>>({ const { data } = await net.request<Step[] | { list: Step[] }>({
url: `/api/steps?pipelineId=${pipelineId}`, url: `/api/steps?pipelineId=${pipelineId}`,
}); });
return data; return Array.isArray(data) ? data : data.list;
} }
// 创建步骤 // 创建步骤
@@ -132,7 +136,7 @@ class DetailService {
'id' | 'createdAt' | 'updatedAt' | 'createdBy' | 'updatedBy' | 'valid' 'id' | 'createdAt' | 'updatedAt' | 'createdBy' | 'updatedBy' | 'valid'
>, >,
) { ) {
const { data } = await net.request<APIResponse<Step>>({ const { data } = await net.request<Step>({
url: '/api/steps', url: '/api/steps',
method: 'POST', method: 'POST',
data: step, data: step,
@@ -150,7 +154,7 @@ class DetailService {
> >
>, >,
) { ) {
const { data } = await net.request<APIResponse<Step>>({ const { data } = await net.request<Step>({
url: `/api/steps/${id}`, url: `/api/steps/${id}`,
method: 'PUT', method: 'PUT',
data: step, data: step,
@@ -161,7 +165,7 @@ class DetailService {
// 删除步骤 // 删除步骤
async deleteStep(id: number) { async deleteStep(id: number) {
// DELETE请求返回204状态码通过拦截器处理为成功响应 // DELETE请求返回204状态码通过拦截器处理为成功响应
const { data } = await net.request<APIResponse<null>>({ const { data } = await net.request<null>({
url: `/api/steps/${id}`, url: `/api/steps/${id}`,
method: 'DELETE', method: 'DELETE',
}); });
@@ -170,23 +174,23 @@ class DetailService {
// 获取项目的提交记录 // 获取项目的提交记录
async getCommits(projectId: number, branch?: string) { async getCommits(projectId: number, branch?: string) {
const { data } = await net.request<APIResponse<Commit[]>>({ const { data } = await net.request<Commit[] | { list: Commit[] }>({
url: `/api/git/commits?projectId=${projectId}${branch ? `&branch=${branch}` : ''}`, url: `/api/git/commits?projectId=${projectId}${branch ? `&branch=${branch}` : ''}`,
}); });
return data; return Array.isArray(data) ? data : data.list;
} }
// 获取项目的分支列表 // 获取项目的分支列表
async getBranches(projectId: number) { async getBranches(projectId: number) {
const { data } = await net.request<APIResponse<Branch[]>>({ const { data } = await net.request<Branch[] | { list: Branch[] }>({
url: `/api/git/branches?projectId=${projectId}`, url: `/api/git/branches?projectId=${projectId}`,
}); });
return data; return Array.isArray(data) ? data : data.list;
} }
// 创建部署 // 创建部署
async createDeployment(deployment: CreateDeploymentRequest) { async createDeployment(deployment: CreateDeploymentRequest) {
const { data } = await net.request<APIResponse<Deployment>>({ const { data } = await net.request<Deployment>({
url: '/api/deployments', url: '/api/deployments',
method: 'POST', method: 'POST',
data: deployment, data: deployment,
@@ -196,7 +200,7 @@ class DetailService {
// 重新执行部署 // 重新执行部署
async retryDeployment(deploymentId: number) { async retryDeployment(deploymentId: number) {
const { data } = await net.request<APIResponse<Deployment>>({ const { data } = await net.request<Deployment>({
url: `/api/deployments/${deploymentId}/retry`, url: `/api/deployments/${deploymentId}/retry`,
method: 'POST', method: 'POST',
}); });
@@ -205,18 +209,15 @@ class DetailService {
// 获取项目详情(包含工作目录状态) // 获取项目详情(包含工作目录状态)
async getProjectDetail(id: number) { async getProjectDetail(id: number) {
const { data } = await net.request<APIResponse<Project>>({ const { data } = await net.request<Project>({
url: `/api/projects/${id}`, url: `/api/projects/${id}`,
}); });
return data; return data;
} }
// 更新项目 // 更新项目
async updateProject( async updateProject(id: number, project: Partial<Project>) {
id: number, const { data } = await net.request<Project>({
project: Partial<Project>,
) {
const { data } = await net.request<APIResponse<Project>>({
url: `/api/projects/${id}`, url: `/api/projects/${id}`,
method: 'PUT', method: 'PUT',
data: project, data: project,
@@ -234,3 +235,10 @@ class DetailService {
} }
export const detailService = new DetailService(); export const detailService = new DetailService();
export interface DeploymentListResponse {
list: Deployment[];
page: number;
pageSize: number;
total: number;
}

View File

@@ -1,13 +1,5 @@
import { import { Button, Form, Input, Message, Modal } from '@arco-design/web-react';
Button,
Collapse,
Form,
Input,
Message,
Modal,
} from '@arco-design/web-react';
import { useState } from 'react'; import { useState } from 'react';
import EnvPresetsEditor from '../../detail/components/EnvPresetsEditor';
import type { Project } from '../../types'; import type { Project } from '../../types';
import { projectService } from '../service'; import { projectService } from '../service';
@@ -30,15 +22,7 @@ function CreateProjectModal({
const values = await form.validate(); const values = await form.validate();
setLoading(true); setLoading(true);
// 序列化环境预设 const newProject = await projectService.create(values);
const submitData = {
...values,
envPresets: values.envPresets
? JSON.stringify(values.envPresets)
: undefined,
};
const newProject = await projectService.create(submitData);
Message.success('项目创建成功'); Message.success('项目创建成功');
onSuccess(newProject); onSuccess(newProject);
@@ -142,14 +126,6 @@ function CreateProjectModal({
> >
<Input placeholder="请输入绝对路径,如: /data/projects/my-app" /> <Input placeholder="请输入绝对路径,如: /data/projects/my-app" />
</Form.Item> </Form.Item>
<Collapse defaultActiveKey={[]} style={{ marginTop: 16 }}>
<Collapse.Item header="环境变量预设配置(可选)" name="envPresets">
<Form.Item field="envPresets" noStyle>
<EnvPresetsEditor />
</Form.Item>
</Collapse.Item>
</Collapse>
</Form> </Form>
</Modal> </Modal>
); );

View File

@@ -59,7 +59,7 @@ function ProjectCard({ project }: ProjectCardProps) {
return ( return (
<Card <Card
className="foka-card !rounded-xl border border-gray-200 h-[280px] cursor-pointer" className="!rounded-xl border border-gray-200 h-[280px] cursor-pointer"
hoverable hoverable
bodyStyle={{ padding: '20px' }} bodyStyle={{ padding: '20px' }}
onClick={onProjectClick} onClick={onProjectClick}

View File

@@ -15,7 +15,7 @@ function ProjectPage() {
useAsyncEffect(async () => { useAsyncEffect(async () => {
const response = await projectService.list(); const response = await projectService.list();
setProjects(response.data); setProjects(response.list);
}, []); }, []);
const handleCreateProject = () => { const handleCreateProject = () => {

View File

@@ -1,18 +1,20 @@
import { type APIResponse, net } from '@shared'; import { net } from '../../../utils';
import type { Project } from '../types'; import type { Project } from '../types';
class ProjectService { class ProjectService {
async list(params?: ProjectQueryParams) { async list(params?: ProjectQueryParams) {
const { data } = await net.request<APIResponse<ProjectListResponse>>({ const { data } = await net.request<Project[] | ProjectListResponse>({
method: 'GET', method: 'GET',
url: '/api/projects', url: '/api/projects',
params, params,
}); });
return data; return Array.isArray(data)
? { list: data, page: 1, pageSize: data.length, total: data.length }
: data;
} }
async show(id: string) { async show(id: string) {
const { data } = await net.request<APIResponse<Project>>({ const { data } = await net.request<Project>({
method: 'GET', method: 'GET',
url: `/api/projects/${id}`, url: `/api/projects/${id}`,
}); });
@@ -24,7 +26,7 @@ class ProjectService {
description?: string; description?: string;
repository: string; repository: string;
}) { }) {
const { data } = await net.request<APIResponse<Project>>({ const { data } = await net.request<Project>({
method: 'POST', method: 'POST',
url: '/api/projects', url: '/api/projects',
data: project, data: project,
@@ -36,7 +38,7 @@ class ProjectService {
id: string, id: string,
project: Partial<{ name: string; description: string; repository: string }>, project: Partial<{ name: string; description: string; repository: string }>,
) { ) {
const { data } = await net.request<APIResponse<Project>>({ const { data } = await net.request<Project>({
method: 'PUT', method: 'PUT',
url: `/api/projects/${id}`, url: `/api/projects/${id}`,
data: project, data: project,
@@ -56,17 +58,14 @@ class ProjectService {
export const projectService = new ProjectService(); export const projectService = new ProjectService();
interface ProjectListResponse { interface ProjectListResponse {
data: Project[]; list: Project[];
pagination: { page: number;
page: number; pageSize: number;
limit: number; total: number;
total: number;
totalPages: number;
};
} }
interface ProjectQueryParams { interface ProjectQueryParams {
page?: number; page?: number;
limit?: number; pageSize?: number;
name?: string; name?: string;
} }

View File

@@ -1,25 +1,13 @@
import { type APIResponse, net } from '@shared'; import { net } from '@utils';
import { create } from 'zustand'; import { create } from 'zustand';
import type { GlobalStore } from './types';
interface User { import type { User } from '@pages/login/types';
id: string;
username: string;
email: string;
avatar_url: string;
active: boolean;
}
interface GlobalStore {
user: User | null;
setUser: (user: User) => void;
refreshUser: () => Promise<void>;
}
export const useGlobalStore = create<GlobalStore>((set) => ({ export const useGlobalStore = create<GlobalStore>((set) => ({
user: null, user: null,
setUser: (user: User) => set({ user }), setUser: (user: User) => set({ user }),
async refreshUser() { async refreshUser() {
const { data } = await net.request<APIResponse<User>>({ const { data } = await net.request<User>({
method: 'GET', method: 'GET',
url: '/api/auth/info', url: '/api/auth/info',
}); });

View File

@@ -0,0 +1,13 @@
interface User {
id: string;
username: string;
email: string;
avatar_url: string;
active: boolean;
}
export interface GlobalStore {
user: User | null;
setUser: (user: User) => void;
refreshUser: () => Promise<void>;
}

View File

@@ -42,9 +42,9 @@ class Net {
); );
} }
async request<T>(config: AxiosRequestConfig): Promise<T> { async request<T>(config: AxiosRequestConfig): Promise<APIResponse<T>> {
try { try {
const response = await this.instance.request<T>(config); const response = await this.instance.request<APIResponse<T>>(config);
if (!response || !response.data) { if (!response || !response.data) {
throw new Error('Invalid response'); throw new Error('Invalid response');
} }
@@ -56,6 +56,13 @@ class Net {
} }
} }
export interface APIPagination<T> {
list: T[];
total: number;
page: number;
pageSize: number;
}
export interface APIResponse<T> { export interface APIResponse<T> {
code: number; code: number;
data: T; data: T;

View File

@@ -24,7 +24,8 @@
"@pages/*": ["./src/pages/*"], "@pages/*": ["./src/pages/*"],
"@styles/*": ["./src/styles/*"], "@styles/*": ["./src/styles/*"],
"@assets/*": ["./src/assets/*"], "@assets/*": ["./src/assets/*"],
"@shared": ["./src/shared"] "@utils/*": ["./src/utils/*"],
"@utils": ["./src/utils"]
} }
}, },
"include": ["src"] "include": ["src"]

View File

@@ -1,7 +0,0 @@
# 文档拥有者
- backend: backend-team@example.com
- ops: ops-team@example.com
- product: product-team@example.com
每个文档请在 front-matter 中声明 `owners` 字段。

View File

@@ -1,116 +0,0 @@
---
title: 设计文档模板
summary: 记录一个功能/模块的设计方案、权衡与落地计划(建议配套 ADR
owners:
- team: <team>
reviewers:
- <name-or-team>
status: draft
date: 2026-01-03
version: 0.1.0
related:
- adr: docs/architecture/adr-xxxx-<slug>.md
- pr: <link>
- issue: <link>
---
# 设计文档:<标题>
## 1. 背景Context
- 当前问题是什么?为什么现在要做?
- 相关现状:已有模块/接口/数据模型(附链接)
- 约束:技术栈、部署方式、团队边界、时间/人力
## 2. 目标Goals
- [ ] 目标 1可验证
- [ ] 目标 2可验证
## 3. 非目标Non-goals
- 不做什么(防止范围膨胀)
## 4. 需求与范围Requirements & Scope
- 用户/角色谁会用例如管理员、开发者、CI runner
- 功能需求:
- R1:
- R2:
- 非功能需求:性能、可用性、可维护性、可观测性
## 5. 方案概览High-level Design
- 用 5-10 行描述整体方案(模块、数据流、调用链)
- 关键选择:为什么选这个方案?
## 6. 详细设计Detailed Design
### 6.1 接口/API 设计
- 新增/变更端点(路径、方法、权限、请求/响应示例)
- 错误码与错误语义(与 `BusinessError` 对齐)
### 6.2 数据模型/数据库
- Prisma model 变更(字段、索引、迁移策略)
- 数据一致性与幂等策略(例如:重试/重复提交)
### 6.3 任务/队列/异步处理(如有)
- 队列模型:入队、出队、并发、重试、死信/失败处理
- 状态机:状态枚举与迁移
### 6.4 配置与环境变量
- 新增 env默认值、是否敏感、是否需要重启
### 6.5 可观测性
- 日志关键日志点、traceId/requestId如有
- 指标:成功率、延迟、队列长度、失败原因分布
- 告警P0/P1 触发条件
### 6.6 安全与权限
- 认证是否要求登录session
- 授权:角色/资源权限(项目级、流水线级)
- 数据安全敏感信息、token、日志脱敏
## 7. 影响与权衡Trade-offs
- 性能影响
- 运维影响
- 对现有接口/调用方的影响
- 技术债与后续演进
## 8. 兼容性与迁移Compatibility & Migration
- 是否 breaking change
- 迁移步骤DB、配置、数据回填
- 回滚策略
## 9. 测试计划Test Plan
- 单测:覆盖哪些模块
- 集成测试:关键链路(如:创建部署 -> 入队 -> 执行 -> 状态更新)
- 手工验证:步骤清单
## 10. 发布计划Rollout Plan
- 分阶段:灰度/开关/逐步放量(如有)
- 监控指标与验收标准
## 11. 备选方案Alternatives Considered
- 方案 A为什么不用
- 方案 B为什么不用
## 12. 风险与开放问题Risks & Open Questions
- 风险 1
- 问题 1需要谁来决定/何时决定)
## 13. 附录Appendix
- 相关链接控制器、DTO、Prisma schema、PR 等

View File

@@ -1,22 +0,0 @@
---
title: Runbook 模板
owners:
- ops: ops-team
status: draft
---
# Runbook 标题
## 触发条件
## 负责人
## 联系方式
## 暂时性缓解
## 恢复步骤
## 验证
## 回滚(如果适用)

22
docs/README.md Normal file
View File

@@ -0,0 +1,22 @@
# MiniCI 文档中心
欢迎查阅 MiniCI 开发者文档。本项目是一个轻量级的自研 CI 系统。
## 目录索引
- [系统架构](./architecture.md) - 核心设计与模块划分
- [决策记录 (ADR)](./decisions/0001-tech-stack.md) - 技术选型背后的逻辑
- [约束与禁区](./constraints.md) - 开发中不可触碰的红线
- [编码规范](./conventions.md) - 风格与最佳实践
- [踩坑指南](./pitfalls.md) - 已解决的问题与易错点
- [当前进度](./status.md) - 项目现状与待办
- [AI 助手说明](./ai.md) - 专门为 coding agent 准备的作业指南
## 快速上手
```bash
pnpm install
pnpm dev
```
项目访问:前端 `localhost:3000` (Rsbuild),后端 `localhost:3001` (Koa)。

21
docs/ai.md Normal file
View File

@@ -0,0 +1,21 @@
# AI 助手作业指南 (ai.md)
你好Agent在处理 MiniCI 项目时,请遵循以下原则:
## 1. 增加新 API 的步骤
1.`controllers/` 对应模块下创建/修改 `dto.ts` 定义输入。
2.`index.ts` 中编写类,使用 `@Controller``@Post/Get` 等装饰器。
3. 如果涉及数据库,修改 `schema.prisma` 并运行 `npx prisma db push`
## 2. 核心逻辑位置
- 如果要修改 **流水线如何运行**,请看 `apps/server/runners/pipeline-runner.ts`
- 如果要修改 **任务调度**,请看 `apps/server/libs/execution-queue.ts`
- 如果要修改 **路由扫描**,请看 `apps/server/libs/route-scanner.ts`
## 3. 交互规范
- 前端 `import` 代码优先使用路径别名,例如:`import {net} from '@utils'`
- 始终保持代码简洁,优先使用现有的 `libs` 工具类。
- 修改代码后,务必确认 `pnpm dev` 是否能正常编译通过。

View File

@@ -1,14 +0,0 @@
---
title: API 文档
summary: 本目录存放 OpenAPI 定义与 API 使用说明。
tags: [api]
owners:
- team: backend
status: stable
---
# API 文档
本目录包含 OpenAPI 规范与示例。可使用 Swagger UI 或 Redoc 渲染 `openapi.yaml`
- OpenAPI: `openapi.yaml`

View File

@@ -1,78 +0,0 @@
---
title: API 端点总览
summary: 基于 `apps/server` 控制器实现的主要 REST API 端点汇总。
owners:
- team: backend
status: stable
---
# API 端点总览
基础前缀:`/api`
下面列出当前实现的主要控制器与常用端点。
## Projects (`/api/projects`)
- GET `/api/projects` : 列表(支持分页与按 name 搜索)
- GET `/api/projects/:id` : 获取单个项目(包含 workspace 状态)
- POST `/api/projects` : 创建项目body: `name`, `repository`, `projectDir` 等)
- PUT `/api/projects/:id` : 更新项目
- DELETE `/api/projects/:id` : 软删除(将 `valid` 置为 0
示例:
```http
GET /api/projects?page=1&limit=10
```
## User (`/api/user`)
- GET `/api/user/list` : 模拟用户列表
- GET `/api/user/detail/:id` : 用户详情
- POST `/api/user` : 创建用户
- PUT `/api/user/:id` : 更新用户
- DELETE `/api/user/:id` : 删除用户
- GET `/api/user/search` : 搜索用户
## Auth (`/api/auth`)
- GET `/api/auth/url` : 获取 Gitea OAuth 授权 URL
- POST `/api/auth/login` : 使用 OAuth code 登录(返回 session
- GET `/api/auth/logout` : 登出
- GET `/api/auth/info` : 当前会话用户信息
注意:需要配置 `GITEA_URL``GITEA_CLIENT_ID``GITEA_REDIRECT_URI`
## Deployments (`/api/deployments`)
- GET `/api/deployments` : 列表(支持 projectId 过滤)
- POST `/api/deployments` : 创建部署(会将任务加入执行队列)
- POST `/api/deployments/:id/retry` : 重新执行某次部署(复制记录并 requeue
## Pipelines (`/api/pipelines`)
- GET `/api/pipelines` : 列表(含 steps
- GET `/api/pipelines/templates` : 获取可用流水线模板
- GET `/api/pipelines/:id` : 单个流水线(含步骤)
- POST `/api/pipelines` : 创建流水线
- POST `/api/pipelines/from-template` : 基于模板创建流水线
- PUT `/api/pipelines/:id` : 更新流水线
- DELETE `/api/pipelines/:id` : 软删除
## Steps (`/api/steps`)
- GET `/api/steps` : 列表(支持 pipelineId 过滤)
- GET `/api/steps/:id` : 单个步骤
- POST `/api/steps` : 创建步骤(包含 `script` 字段)
- PUT `/api/steps/:id` : 更新步骤
- DELETE `/api/steps/:id` : 软删除
## Git (`/api/git`)
- GET `/api/git/commits?projectId=&branch=` : 获取指定项目的提交列表(调用 Gitea
- GET `/api/git/branches?projectId=` : 获取分支列表
---
想要更详细的示例(请求 body、响应 schema我可以为每个端点基于 `dto.ts` 自动生成示例请求/响应片段。是否需要我继续生成?

View File

@@ -1,28 +0,0 @@
openapi: 3.0.1
info:
title: Foka-CI 示例 API
version: '1.0.0'
paths:
/health:
get:
summary: 健康检查
responses:
'200':
description: OK
/projects:
get:
summary: 列出项目
responses:
'200':
description: 项目列表
content:
application/json:
schema:
type: array
items:
type: object
properties:
id:
type: string
name:
type: string

29
docs/architecture.md Normal file
View File

@@ -0,0 +1,29 @@
# 系统架构
## 1. 概览
MiniCI 采用典型的 **Monorepo** 架构,前端 React 配合后端 Koa数据持久化使用 SQLite。
## 2. 核心模块
### 2.1 后端 (apps/server)
- **Route System**: 基于 TC39 装饰器的自动扫描路由。
- **Execution Queue**: 单例模式的执行队列,控制并发并支持任务持久化恢复。
- **Pipeline Runner**: 核心执行逻辑,利用 `zx` 在独立的工作目录下运行 Shell 脚本。
- **Data Access**: Prisma ORM 提供类型安全的数据访问。
### 2.2 前端 (apps/web)
- **Build Tool**: Rsbuild (基于 Rspack),提供极速的开发体验。
- **UI Framework**: React 19 + Arco Design。
- **State Management**: Zustand 实现轻量级全局状态。
## 3. 部署流 (Pipeline Flow)
1. 用户触发部署 -> 创建 `Deployment` 记录 (Status: pending)。
2. `ExecutionQueue` 捕获新任务 -> 实例化 `PipelineRunner`
3. `GitManager` 准备工作目录 (`git clone``git pull`)。
4. `PipelineRunner` 逐个执行 `Step` 脚本。
5. 执行过程中实时更新 `Deployment.buildLog`
6. 完成后更新状态为 `success``failed`

View File

@@ -1,26 +0,0 @@
---
title: ADR 0001 - 服务设计决策
date: 2026-01-03
authors:
- backend-team
status: accepted
---
# ADR 0001: 服务设计与部署模型
## 背景
需要选择微服务还是单体部署以便平衡开发速度与运维复杂度。
## 决策
采用模块化单体modular monolith作为初始阶段部署方式关键模块解耦、接口明确后续按需拆分服务。
## 影响
- 优点:降低初期运维成本,便于本地调试与 CI 集成。
- 缺点:需要在代码边界设计中预留拆分点。
## 备注
在拆分时优先考虑数据库边界和独立部署能力。

View File

@@ -1,175 +0,0 @@
---
title: 设计文档 0001 - 产品原型
summary: 产品的设计原型
owners:
- team: backend
reviewers:
- ops-team
status: draft
date: 2026-01-03
version: 0.1.0
---
# 设计文档 0001产品整体原型
## 1. 背景Context
企业内部代码发布是非常频繁的事情,无论是测试环境还是生产环境,发布代码常常面临着一些痛点:频繁发布浪费开发时;手动发布代码可能漏掉某些步骤、造成环境报错。所以需要引入 CI/CD 工具让发布流程自动化,市场上已经存在很多解决类似问题的产品,例如 Drone CI、jenkins 等,但是这些工具不够灵活、不能满足全部场景,所以便有了 Foka-CI 这款产品。
## 2. 拆解需求
### 登录认证页面
仅提供 Gitea 的 OAuth 认证登录,方便获取仓库信息,简化密码登录过程
### 项目列表页面
查看所有项目,项目的基本信息有:
- 项目名
- 项目描述(可选)
- 代码仓库地址
- 项目的工作目录,用于将代码克隆到该目录下,发布流水线脚本在该目录下执行
创建项目,需要填写基本信息
### 项目详情页
## 2. 目标Goals
- [ ] 明确“部署任务”从创建到执行的状态流转与约束
- [ ] 明确重试语义retry 会创建新 deployment而不是复用原记录
- [ ] 降低重复入队/重复执行的风险(幂等与并发边界清晰)
- [ ] 让运维/排障更容易:关键日志点与可观测性清单
## 3. 非目标Non-goals
- 不引入外部消息队列(如 Redis/Kafka仍以当前内存队列 + DB 恢复为基础
- 不在本文直接实现大规模调度/分布式 runner后续 ADR/设计再做)
## 4. 需求与范围Requirements & Scope
### 功能需求
- 创建部署:写入一条 Deployment 记录,初始状态为 `pending`,并加入执行队列
- 重试部署:通过 `/deployments/:id/retry` 创建一条新的 Deployment 记录(复制必要字段),并加入执行队列
- 服务重启恢复:服务启动后能从 DB 找回仍为 `pending` 的任务并继续执行
### 非功能需求
- 可靠性服务重启不会“丢任务”pending 的任务能恢复)
- 幂等性:避免同一个 deployment 被重复执行
- 可观测性:能定位某次部署为何失败、何时开始/结束、队列长度
## 5. 方案概览High-level Design
当前方案核心链路如下:
1. API 创建 Deploymentstatus=pending
2. `ExecutionQueue.addTask(deploymentId, pipelineId)` 入队
3. `ExecutionQueue.processQueue()` 串行消费 `pendingQueue`
4. `executePipeline()` 会读取 Deployment 与关联 Project获取 `projectDir`,然后创建 `PipelineRunner` 执行
5. 定时轮询:每 30 秒扫描 DB 中的 pending 任务,若不在 `runningDeployments` 集合则补入队列
## 6. 详细设计Detailed Design
### 6.1 接口/API 设计
#### 创建部署
- POST `/api/deployments`
- 行为:创建 Deployment 后立即入队
#### 重试部署
- POST `/api/deployments/:id/retry`
- 行为:读取原 deployment -> 创建新 deploymentstatus=pending-> 入队
- 语义:重试是“创建一个新的任务实例”,便于保留历史执行记录
### 6.2 数据模型/数据库
Deployment 当前关键字段(见 schema 注释):
- `status`: pending/running/success/failed/cancelled目前入队依赖 pending
- `buildLog`: 执行日志(当前创建时写空字符串)
- `startedAt`/`finishedAt`: 时间标记(目前 created 时 startedAt 默认 now
建议补齐/明确(文档层面约束,代码后续落地):
- 状态迁移:
- `pending` -> `running`(开始执行前)
- `running` -> `success|failed|cancelled`(结束后)
- 幂等控制:
- 以 deploymentId 为“单次执行唯一标识”,同一个 deploymentId 不允许重复开始执行
### 6.3 队列/轮询/并发
现状:
- `runningDeployments` 同时承担“已入队/执行中”的去重集合
- `pendingQueue` 为内存 FIFO
- 单实例串行消费(`processQueue` while 循环)
- 轮询间隔常量 30 秒
风险点(需要在文档中明确约束/后续逐步修正):
- 多实例部署:如果将来启动多个 server 实例,每个实例都可能轮询到同一条 pending 记录并执行(需要 DB 锁/租约/状态原子更新)
- 状态更新缺口:当前 `ExecutionQueue` 代码中没有看到明确把 status 从 pending 改成 running/failed/success 的逻辑(可能在 `PipelineRunner` 内处理;若没有,需要补齐)
建议(不改变整体架构前提):
- 将轮询间隔改为可配置 env`EXECUTION_POLL_INTERVAL_MS`(默认 30000
- 在真正执行前做一次 DB 原子“抢占”:仅当 status=pending 时更新为 running并记录开始时间更新失败则放弃执行
### 6.4 可观测性
最低要求(建议后续落地到代码/日志规范):
- 日志字段deploymentId、pipelineId、projectId、projectDir、status
- 队列指标pendingQueue length、runningDeployments size
- 失败记录:捕获异常 message/stack避免泄露敏感信息
### 6.5 安全与权限
当前接口层面需要确认:
- `/api/deployments``/api/deployments/:id/retry` 是否需要登录/鉴权(取决于 middleware 配置)
- 若需要鉴权:建议限制为有项目权限的用户才能创建/重试部署
## 7. 影响与权衡Trade-offs
- 继续采用内存队列:实现简单,但天然不支持多实例并发安全
- DB 轮询恢复:可靠性提升,但会带来额外 DB 查询压力
## 8. 兼容性与迁移Compatibility & Migration
- 文档层面不破坏现有 API
- 若引入 status 原子抢占,需要确保旧数据/旧状态兼容(例如对历史 pending 记录仍可恢复)
## 9. 测试计划Test Plan
- 集成链路:创建 deployment -> 入队 -> 触发执行(可用假 runner
- 重启恢复:插入 pending 记录 -> initialize() -> 任务被 addTask
- 重试接口:原记录存在/不存在的分支
## 10. 发布计划Rollout Plan
- 先补齐文档 + 最小日志规范
- 再逐步落地status 原子抢占 + 轮询间隔 env
## 11. 备选方案Alternatives Considered
- 引入 Redis 队列BullMQ 等):更可靠、支持多实例,但复杂度上升
- 使用 DB 作为队列(表 + 锁/租约):更可靠,但需要严格的并发控制
## 12. 风险与开放问题Risks & Open Questions
- Q1`PipelineRunner` 是否负责更新 Deployment.status如果没有状态机应由谁维护
- Q2服务是否计划多实例部署如果是必须补齐“抢占执行”机制
## 13. 附录Appendix
- 代码:`apps/server/libs/execution-queue.ts`
- 控制器:`apps/server/controllers/deployment/index.ts`
- Schema`apps/server/prisma/schema.prisma`

View File

@@ -1,166 +0,0 @@
---
title: 设计文档 0001 - 部署执行队列与重试(基于当前实现)
summary: 记录当前 ExecutionQueue 的行为与下一步改进方向,便于团队对齐。
owners:
- team: backend
reviewers:
- ops-team
status: draft
date: 2026-01-03
version: 0.1.0
related:
- code: apps/server/libs/execution-queue.ts
- code: apps/server/controllers/deployment/index.ts
- schema: apps/server/prisma/schema.prisma
---
# 设计文档 0001部署执行队列与重试
## 1. 背景Context
当前服务端在启动时会初始化执行队列(`ExecutionQueue.initialize()`),用于从数据库恢复 `status=pending` 的部署任务并按顺序执行流水线。
现有相关事实(来自当前代码):
- 服务启动入口:`apps/server/app.ts`
- 路由:`/api/deployments`(创建部署后入队)与 `/api/deployments/:id/retry`(复制记录后入队)
- 数据库SQLite + PrismaDeployment 存在 `status` 字段注释标明pending/running/success/failed/cancelled
- 队列实现:内存队列 `pendingQueue` + `runningDeployments` Set并有轮询机制默认 30 秒)把 DB 中的 pending 任务补进队列
## 2. 目标Goals
- [ ] 明确“部署任务”从创建到执行的状态流转与约束
- [ ] 明确重试语义retry 会创建新 deployment而不是复用原记录
- [ ] 降低重复入队/重复执行的风险(幂等与并发边界清晰)
- [ ] 让运维/排障更容易:关键日志点与可观测性清单
## 3. 非目标Non-goals
- 不引入外部消息队列(如 Redis/Kafka仍以当前内存队列 + DB 恢复为基础
- 不在本文直接实现大规模调度/分布式 runner后续 ADR/设计再做)
## 4. 需求与范围Requirements & Scope
### 功能需求
- 创建部署:写入一条 Deployment 记录,初始状态为 `pending`,并加入执行队列
- 重试部署:通过 `/deployments/:id/retry` 创建一条新的 Deployment 记录(复制必要字段),并加入执行队列
- 服务重启恢复:服务启动后能从 DB 找回仍为 `pending` 的任务并继续执行
### 非功能需求
- 可靠性服务重启不会“丢任务”pending 的任务能恢复)
- 幂等性:避免同一个 deployment 被重复执行
- 可观测性:能定位某次部署为何失败、何时开始/结束、队列长度
## 5. 方案概览High-level Design
当前方案核心链路如下:
1) API 创建 Deploymentstatus=pending
2) `ExecutionQueue.addTask(deploymentId, pipelineId)` 入队
3) `ExecutionQueue.processQueue()` 串行消费 `pendingQueue`
4) `executePipeline()` 会读取 Deployment 与关联 Project获取 `projectDir`,然后创建 `PipelineRunner` 执行
5) 定时轮询:每 30 秒扫描 DB 中的 pending 任务,若不在 `runningDeployments` 集合则补入队列
## 6. 详细设计Detailed Design
### 6.1 接口/API 设计
#### 创建部署
- POST `/api/deployments`
- 行为:创建 Deployment 后立即入队
#### 重试部署
- POST `/api/deployments/:id/retry`
- 行为:读取原 deployment -> 创建新 deploymentstatus=pending-> 入队
- 语义:重试是“创建一个新的任务实例”,便于保留历史执行记录
### 6.2 数据模型/数据库
Deployment 当前关键字段(见 schema 注释):
- `status`: pending/running/success/failed/cancelled目前入队依赖 pending
- `buildLog`: 执行日志(当前创建时写空字符串)
- `startedAt`/`finishedAt`: 时间标记(目前 created 时 startedAt 默认 now
建议补齐/明确(文档层面约束,代码后续落地):
- 状态迁移:
- `pending` -> `running`(开始执行前)
- `running` -> `success|failed|cancelled`(结束后)
- 幂等控制:
- 以 deploymentId 为“单次执行唯一标识”,同一个 deploymentId 不允许重复开始执行
### 6.3 队列/轮询/并发
现状:
- `runningDeployments` 同时承担“已入队/执行中”的去重集合
- `pendingQueue` 为内存 FIFO
- 单实例串行消费(`processQueue` while 循环)
- 轮询间隔常量 30 秒
风险点(需要在文档中明确约束/后续逐步修正):
- 多实例部署:如果将来启动多个 server 实例,每个实例都可能轮询到同一条 pending 记录并执行(需要 DB 锁/租约/状态原子更新)
- 状态更新缺口:当前 `ExecutionQueue` 代码中没有看到明确把 status 从 pending 改成 running/failed/success 的逻辑(可能在 `PipelineRunner` 内处理;若没有,需要补齐)
建议(不改变整体架构前提):
- 将轮询间隔改为可配置 env`EXECUTION_POLL_INTERVAL_MS`(默认 30000
- 在真正执行前做一次 DB 原子“抢占”:仅当 status=pending 时更新为 running并记录开始时间更新失败则放弃执行
### 6.4 可观测性
最低要求(建议后续落地到代码/日志规范):
- 日志字段deploymentId、pipelineId、projectId、projectDir、status
- 队列指标pendingQueue length、runningDeployments size
- 失败记录:捕获异常 message/stack避免泄露敏感信息
### 6.5 安全与权限
当前接口层面需要确认:
- `/api/deployments``/api/deployments/:id/retry` 是否需要登录/鉴权(取决于 middleware 配置)
- 若需要鉴权:建议限制为有项目权限的用户才能创建/重试部署
## 7. 影响与权衡Trade-offs
- 继续采用内存队列:实现简单,但天然不支持多实例并发安全
- DB 轮询恢复:可靠性提升,但会带来额外 DB 查询压力
## 8. 兼容性与迁移Compatibility & Migration
- 文档层面不破坏现有 API
- 若引入 status 原子抢占,需要确保旧数据/旧状态兼容(例如对历史 pending 记录仍可恢复)
## 9. 测试计划Test Plan
- 集成链路:创建 deployment -> 入队 -> 触发执行(可用假 runner
- 重启恢复:插入 pending 记录 -> initialize() -> 任务被 addTask
- 重试接口:原记录存在/不存在的分支
## 10. 发布计划Rollout Plan
- 先补齐文档 + 最小日志规范
- 再逐步落地status 原子抢占 + 轮询间隔 env
## 11. 备选方案Alternatives Considered
- 引入 Redis 队列BullMQ 等):更可靠、支持多实例,但复杂度上升
- 使用 DB 作为队列(表 + 锁/租约):更可靠,但需要严格的并发控制
## 12. 风险与开放问题Risks & Open Questions
- Q1`PipelineRunner` 是否负责更新 Deployment.status如果没有状态机应由谁维护
- Q2服务是否计划多实例部署如果是必须补齐“抢占执行”机制
## 13. 附录Appendix
- 代码:`apps/server/libs/execution-queue.ts`
- 控制器:`apps/server/controllers/deployment/index.ts`
- Schema`apps/server/prisma/schema.prisma`

View File

@@ -1,127 +0,0 @@
---
title: 设计文档 0005 - 部署流程重构(移除稀疏检出 & 环境预设)
summary: 调整部署相关能力:移除稀疏检出;将部署环境从创建时输入改为在项目设置中预设。
owners:
- team: backend
reviewers:
- team: frontend
status: draft
date: 2026-01-03
version: 0.1.0
related:
- docs: docs/api/endpoints.md
- schema: apps/server/prisma/schema.prisma
---
# 设计文档 0005部署流程重构移除稀疏检出 & 环境预设)
## 1. 背景Context
当前部署流程在“项目详情页发起部署”时包含“稀疏检出sparse checkout”表单项并且流水线模板中也包含与稀疏检出相关的逻辑。
另外,部署时需要指定环境变量(例如 env但目前是在“创建部署”时临时输入/选择。随着项目数量增加,这种方式容易造成不一致与误操作。
## 2. 目标Goals
- [ ] 移除项目详情页部署表单中的“稀疏检出”相关输入项
- [ ] 移除流水线模板中与稀疏检出相关的代码逻辑(后端模板/生成逻辑)
- [ ] 将“部署环境env”从创建部署时指定调整为在“项目设置”中提前预设
- [ ] 创建部署时仍需要选择/指定环境,但选项来源于项目设置中的预设项
## 3. 非目标Non-goals
- 不新增多维度环境变量管理(仅覆盖本次提到的 env 单项预设)
- 不在本次引入复杂的环境权限、审批流
## 4. 需求与范围Requirements & Scope
### 4.1 移除稀疏检出
#### 用户侧
- 项目详情页发起部署时:不再展示/提交稀疏检出字段
#### 系统侧
- 流水线模板:移除任何基于稀疏检出路径的生成/执行逻辑
> 说明:当前 DB 中 Deployment 仍存在 `sparseCheckoutPaths` 字段(见 `schema.prisma`),本次需求仅明确“功能不再需要”。字段是否删除/迁移由本设计后续章节确定。
### 4.2 部署环境 env 改为项目设置预设
#### 核心约束
- 环境变量预设需要支持多选、单选、输入框这几种类型
- 在项目设置中新增可配置项(预设项):
例如指定env 环境变量
- 类型单选single select
- key`env`value 及时部署是选中的候选项的值
- options`staging`(测试环境)、`production`(生产环境)
#### 行为
- 创建部署时仍需指定环境env
- 不再由用户自由输入
- 只允许从该项目预设的 options 中选择
## 5. 影响面Impact
### 5.1 前端
- 项目详情页部署表单:移除“稀疏检出”相关 UI 与字段提交
- 项目设置页新增“环境预设env”配置入口单选 + 选项 staging/production
- 创建部署交互:环境选项从项目设置读取(不再硬编码/临时输入)
### 5.2 后端
- 部署创建接口:校验 env 必须来自项目预设(避免非法 env
- 流水线模板:移除稀疏检出相关的模板字段/生成逻辑
### 5.3 数据库
- 需要新增“项目设置/项目配置”承载 env 预设(落库方案待定)
- 既有 Deployment 的 `sparseCheckoutPaths` 字段:后续决定是否保留(兼容历史)或迁移删除
## 6. 兼容性与迁移Compatibility & Migration
- 对历史部署记录:
- 若存在 `sparseCheckoutPaths`,不影响查询展示,但新建部署不再写入该字段
- 对创建部署:
- 若项目未配置 env 预设:创建部署应失败并提示先到项目设置配置(或提供默认值策略,待确认)
## 7. 测试要点Test Plan
- 前端:
- 项目详情页部署表单不再出现稀疏检出项
- 项目设置可保存 env 预设(单选)并在创建部署时正确展示
- 后端:
- 创建部署env 不在项目预设 options 内时应拒绝
- 流水线模板:移除稀疏检出后仍能正常创建并执行
## 8. 实施状态Implementation Status
### 已完成(后端)
- ✅ Prisma Schema在 Project 表添加 `envPresets` 字段String? 类型,存储 JSON
- ✅ 移除部署创建/重试接口中的 `sparseCheckoutPaths` 写入
- ✅ 在部署创建接口添加环境校验:验证 env 是否在项目 envPresets 的 options 中
- ✅ 更新 project DTO 和 controller 支持 envPresets 读写
- ✅ 移除 pipeline-runner 中的 `SPARSE_CHECKOUT_PATHS` 环境变量
- ✅ 生成 Prisma Client
### 已完成(前端)
- ✅ 创建 EnvPresetsEditor 组件(支持单选、多选、输入框类型)
- ✅ 在 CreateProjectModal 和 EditProjectModal 中集成环境预设编辑器
- ✅ 从 DeployModal 移除稀疏检出表单项
- ✅ 在 DeployModal 中从项目 envPresets 读取环境选项并展示
- ✅ 移除 DeployModal 中的动态环境变量列表envVars Form.List
- ✅ 从类型定义中移除 sparseCheckoutPaths 字段
- ✅ 在项目详情页项目设置 tab 中添加环境变量预设的查看和编辑功能
### 待定问题
- Q1项目设置存储方式 → **已决定**:使用 Project.envPresets JSON 字段
- Q2未配置 env 预设的默认行为 → **已实现**:若配置了预设则校验,否则允许任意值(向后兼容)
- Q3Deployment.sparseCheckoutPaths 字段 → **已决定**:保留字段(兼容历史),但新建部署不再写入

View File

@@ -1,9 +0,0 @@
# Changelog
所有 notable 更改应在此记录。遵循 Keep a Changelog 格式。
## [Unreleased]
- 初始文档目录建立。
## [1.0.0] - 2026-01-03
- 初始发布

20
docs/constraints.md Normal file
View File

@@ -0,0 +1,20 @@
# 约束与禁区
## 1. 路由规范
- **禁止** 在 `app.ts` 中手动编写 `router.get/post`
- **必须** 使用 `@Controller``@Get/Post` 装饰器,并放在 `controllers/` 目录下。
## 2. 数据库安全
- **禁止** 绕过 Prisma 直接操作数据库文件。
- **禁止** 在生产环境中手动修改 `dev.db`
## 3. 环境变量
- **禁区**: 严禁将敏感 Token 或密钥直接硬编码在代码或 `schema.prisma` 中。
- 请使用 `.env` 文件配合 `dotenv` 加载。
## 4. 依赖管理
- **禁止** 混合使用 npm/yarn必须统一使用 `pnpm`

86
docs/conventions.md Normal file
View File

@@ -0,0 +1,86 @@
# 编码规范
## 1. 命名习惯
- **前端组件**: PascalCase (如 `ProjectCard.tsx`)。
- **后端文件**: kebab-case (如 `route-scanner.ts`)。
- **DTO**: 文件名为 `dto.ts`,类名为 `XxxDTO`
## 2. 代码组织
web 项目代码组织如下:
```yaml
├── web/
│ ├── pages/
│ │ ├── components/ # 页面组件
│ │ ├── index.tsx # 页面入口组件
│ │ ├── service.ts # 当前页面使用到的 api 请求方法和纯函数
│ │ ├── types.ts # 当前页面使用到的类型定义
│ │ └── ...
│ └── hooks/ # 通用 hooks (不止一个组件引用)
│ └── stores/ # 全局状态
│ └── utils/ # 通用工具类
│ └── styles/ # 全局样式
│ └── assets/ # 静态资源
│ └── components/ # 通用组件
│ └── types/ # 全局类型定义
│ └── ...
```
## 3. 代码规范
- 注释符合 jsdoc 规范
- 代码简洁避免冗余移除无用的代码引用、变量、函数和css样式
- 禁止使用 any 类型
## 4. 前端发送net请求示例
- 分页
```typescript
import {net} from '@utils';
import type {APIPagination} from '@utils/net';
const data = await net.request<APIPagination<Deployment>>({
method: 'GET',
url: '/api/deployments',
// 注意:查询参数使用 params 传递,不要手动拼接到 url 上
params: {
projectId: 1,
page: 1,
pageSize: 10,
},
})
```
- 其他
```typescript
import {net} from '@utils';
const data = await net.request<void>({
method: 'POST',
url: '/api/deployment',
data: {
name: 'xxx',
description: 'xxx',
repository: 'https://a.com',
}
})
if (data.code === 0) {
console.log("创建成功")
} else {
console.log("创建失败")
}
```
## 5. 异步处理
- 统一使用 `async/await`
- 后端错误通过抛出异常由 `exception.ts` 中间件统一捕获。
## 6. 格式化
- 使用 Biome 进行 Lint 和 Format。
- 提交代码前建议运行 `pnpm --filter web format`

View File

@@ -0,0 +1,17 @@
# ADR 0001: 技术选型
## 背景
需要构建一个轻量级、易扩展且易于本地部署的 CI 系统。
## 决策
- **语言**: 全栈 TypeScript确保模型定义在前后端的一致性。
- **后端框架**: Koa。相比 Express 更加轻量,利用 async/await 处理异步中间件更优雅。
- **数据库**: SQLite。CI 系统通常是单机或小规模使用SQLite 无需独立服务,运维成本极低。
- **执行工具**: `zx`。相比原生的 `child_process``zx` 处理 Shell 交互更加直观和安全。
## 后果
- 优势:开发效率极高,部署简单。
- 挑战SQLite 在极高并发写入(如数百个任务同时输出日志)时可能存在性能瓶颈。

View File

@@ -0,0 +1,18 @@
# ADR 0002: 状态管理
## 背景
需要在前端管理用户信息、全局配置以及各页面的复杂 UI 状态。
## 决策
- **全局状态**: 使用 Zustand。
- **理由**:
- 相比 Redux 模板代码极少。
- 相比 Context API 性能更好且不引起全量重绘。
- 符合 React 19 的 Concurrent 模式。
- **持久化**: 对关键状态(如 Token使用 Zustand 的 persist 中间件。
## 后果
状态管理逻辑高度内聚在 `apps/web/src/stores` 中。

View File

@@ -0,0 +1,15 @@
# ADR 0003: 流水线执行策略
## 背景
如何确保流水线执行的隔离性与可靠性。
## 决策
- **工作目录**: 每个项目在服务器上拥有独立的 `projectDir`
- **执行器**: 采用线性执行。目前不支持多步骤并行,以确保日志顺序的确定性。
- **队列**: 使用内存队列 + 数据库扫描实现。系统重启后能通过数据库中的 `pending` 状态恢复任务。
## 后果
目前的隔离级别为目录级。未来可能需要引入 Docker 容器化执行以增强安全性。

View File

@@ -1,88 +0,0 @@
---
title: 开发与本地环境搭建
summary: 针对本项目的本地开发、数据库与调试指南。
owners:
- team: backend
status: stable
---
# 开发与本地环境搭建
## 1. 安装依赖
建议使用 `pnpm` 管理工作区依赖:
```bash
# 在仓库根
pnpm install
```
或者只在 server 子包安装:
```bash
cd apps/server
pnpm install
```
## 2. 生成 Prisma Client
```bash
cd apps/server
npx prisma generate
```
如果需要执行迁移(开发场景):
```bash
npx prisma migrate dev --name init
```
数据库:项目使用 SQLite`apps/server/prisma/schema.prisma`),迁移会在本地创建 `.db` 文件。
## 3. 启动服务
并行启动 workspace 中所有 dev 脚本(推荐):
```bash
pnpm dev
```
或单独启动 server
```bash
cd apps/server
pnpm dev
```
服务默认端口:`3001`。如需修改:
```bash
PORT=4000 pnpm dev
```
## 4. 常见开发命令
- 运行测试脚本(仓库自带):
```bash
cd apps/server
node test.js
```
- TypeScript 类型检查(本地可使用 `tsc`
```bash
npx tsc --noEmit
```
## 5. 环境变量与第三方集成
常见 env`GITEA_URL`, `GITEA_CLIENT_ID`, `GITEA_REDIRECT_URI`, `PORT`, `NODE_ENV`
登录采用 Gitea OAuth未配置时某些 auth 接口会返回 401需要先登录获取 session。
## 6. 运行与调试要点
- 代码通过装饰器注册路由(见 `apps/server/decorators/route.ts``apps/server/libs/route-scanner.ts`)。
- Prisma client 生成路径:`apps/server/generated`
- 若变更 Prisma schema请执行 `prisma generate` 并更新迁移。

View File

@@ -1,79 +0,0 @@
---
title: 快速开始
summary: 本文档介绍如何在本地启动与运行项目的基础步骤。
tags: [getting-started]
owners:
- team: backend
status: stable
version: 1.0.0
---
# 快速开始
## 前置条件
- Node.js >= 18
- pnpm
- 克隆权限(或访问仓库)
## 克隆与依赖安装
```bash
git clone <repo-url>
cd foka-ci
pnpm install
```
说明:仓库使用 pnpm workspace根目录脚本 `pnpm dev` 会并行启动工作区内的 `dev` 脚本。
## 运行服务(开发)
- 从仓库根(并行运行所有 dev 脚本):
```bash
pnpm dev
```
- 单独运行 server
```bash
cd apps/server
pnpm install
pnpm dev # 等同于: tsx watch ./app.ts
```
服务器默认监听端口 `3001`(可通过 `PORT` 环境变量覆盖。API 前缀为 `/api`
## Prisma 与数据库
项目使用 SQLite`apps/server/prisma/schema.prisma`)。如果需要生成 Prisma Client 或运行迁移:
```bash
cd apps/server
npx prisma generate
npx prisma migrate dev --name init # 本地开发使用
```
生成的 Prisma Client 位于 `apps/server/generated`(由 schema 中的 generator 指定)。
## 环境变量(常用)
- `GITEA_URL``GITEA_CLIENT_ID``GITEA_REDIRECT_URI`:用于 OAuth 登录Gitea
- `PORT`:服务监听端口,默认 `3001`
- `NODE_ENV`环境development/production
将敏感值放入 `.env`(不要将 `.env` 提交到仓库)。
## 运行脚本与测试
```bash
cd apps/server
node test.js # 运行仓库自带的简单测试脚本
```
## 其他说明
- 文档目录位于 `docs/`,设计模板在 `docs/.meta/templates/`
- API 路由由装饰器注册,路由前缀为 `/api`,在 `apps/server/middlewares/router.ts` 中可查看。
更多开发细节请参见 `docs/development/setup.md``docs/api/endpoints.md`

View File

@@ -1,22 +0,0 @@
---
title: 项目文档总览
summary: 项目文档的导航页指向快速开始、架构、API、运维等部分。
tags: [overview]
owners:
- team: backend
status: stable
version: 1.0.0
---
# 文档总览
欢迎来到项目文档。下列是常用文档的入口:
- Getting Started: ./getting-started.md
- Architecture: ./architecture/adr-0001-service-design.md
- API: ./api/README.md
- Runbooks: ./runbooks/incident-response.md
- Onboarding: ./onboarding/new-hire.md
- Changelog: ./changelogs/CHANGELOG.md
维护说明:请在变更同时更新 `owners``version`,并通过 PR 提交。

View File

@@ -1,13 +0,0 @@
---
title: 新成员入职指南
owners:
- people-team
status: draft
---
# 新成员入职快速清单
1. 获取公司邮箱与账号
2. 克隆仓库并运行 `pnpm install`
3. 阅读 `docs/getting-started.md``docs/architecture` 中关键 ADR
4. 约见导师并完成第一次 code walkthrough

21
docs/pitfalls.md Normal file
View File

@@ -0,0 +1,21 @@
# 踩坑指南 (Pitfalls)
## 1. Prisma 客户端生成
- **现象**: 修改 `schema.prisma` 后代码报错找不到类型。
- **解决**: 需要在 `apps/server` 下运行 `pnpm prisma generate`。本项目将生成的代码放在了 `generated/` 目录而非 node_modules请注意引用路径。
## 2. zx 环境变量继承
- **现象**: 在流水线脚本中找不到 `node``git` 命令。
- **解决**: `PipelineRunner` 在调用 `zx` 时必须手动扩展 `env: { ...process.env, ...userEnv }`,否则会丢失系统 PATH。
## 3. Koa BodyParser 顺序
- **现象**: 获取不到 `ctx.request.body`
- **解决**: `koa-bodyparser` 中间件必须在 `router` 中间件之前注册。
## 4. SQLite 并发写入
- **现象**: 部署日志极快输出时偶发 `SQLITE_BUSY`
- **解决**: 适当增加 `better-sqlite3` 的 busy timeout。

View File

@@ -0,0 +1,33 @@
# 标准化响应结构
1. 需要标准化接口响应的结构,修改后端接口中不符合规范的代码。
2. 前端接口类型定义需要与后端接口响应结构保持一致。
## 响应示例
- 列表分页响应
```json
{
"code": 0, // 响应状态码0 表示成功,其他值表示失败
"message": "success", // 响应消息
"data": { // 响应数据
"list": [], // 列表数据
"page": 1, // 当前页码
"pageSize": 10, // 每页显示数量
"total": 10 // 总数量
},
"timestamp": "12346579" // 响应时间戳
}
```
- 其他响应
```json
{
"code": 0,
"message": "success",
"data": {},
"timestamp": "12346579"
}
```

View File

@@ -1,28 +0,0 @@
---
title: 事故响应 Runbook
summary: 处理生产事故的步骤和联系方式。
owners:
- ops-team
status: stable
---
# 事故响应Incident Response
## 1. 识别与分级
- P0: 系统不可用或数据安全泄露
- P1: 主要功能严重受损
## 2. 通知
- 联系人:`on-call@company.example`,电话:+86-10-12345678
## 3. 暂时性缓解
- 回滚最近的部署
- 启用备用服务
## 4. 根因分析与恢复
- 记录时间线
- 生成 RCA 并在 72 小时内发布

23
docs/status.md Normal file
View File

@@ -0,0 +1,23 @@
# 当前进度 (Status)
## 已完成 ✅
- 基础 Monorepo 框架搭设
- TC39 装饰器路由系统
- 项目管理 (CRUD)
- 基础流水线执行流程 (Git Clone -> zx Run -> Log Update)
- 前端项目列表与详情页预览
- 优化: 移除菜单环境管理及页面(目前无用)
- 优化:移除创建 Step 弹窗可用环境变量区域
- 优化: 部署记录的分页查询每页 10 条
## 进行中 🚧
- [标准化接口响应结构](./requirements/0001-Fix-Response-structure.md)
## 待办 📅
- [ ] Gitea Webhook 自动触发
- [ ] 用户权限管理 (RBAC)
- [ ] 修复: 表单必填项,*号和 label 不在一行
- [ ] 修复:项目详情页,未选中 tab【部署记录】还会拉取日志信息

View File

@@ -1,5 +1,5 @@
{ {
"name": "ark-ci", "name": "MiniCI",
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "",
"scripts": { "scripts": {