feat: 增加项目 Gitea 诊断接口

- Gitea client: 增加仓库元数据读取
- projects: 新增 gitea-diagnostics 聚合接口
- project types: 补齐仓库诊断安全响应模型
This commit is contained in:
湛兮
2026-06-12 04:38:51 +08:00
parent 555367a61f
commit a6ed51e107
5 changed files with 247 additions and 1 deletions
+35
View File
@@ -1,3 +1,6 @@
/**
* 封装 Gitea HTTP API 调用,统一处理鉴权、仓库元数据读取和错误脱敏。
*/
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IntegrationRequestError, IntegrationUnavailableError } from '../../common/errors/integration-error';
@@ -7,6 +10,7 @@ import {
GiteaBranch,
GiteaCommitSummary,
GiteaPullRequestSummary,
GiteaRepositoryInfo,
GiteaTag,
} from './gitea.types';
@@ -43,6 +47,37 @@ export class GiteaClient {
}));
}
async getRepository(owner: string, repo: string): Promise<GiteaRepositoryInfo> {
const payload = await this.request<{
name: string;
full_name?: string;
default_branch?: string;
html_url?: string;
clone_url?: string;
archived?: boolean;
private?: boolean;
empty?: boolean;
updated_at?: string;
owner?: {
login?: string;
username?: string;
};
}>(`/api/v1/repos/${owner}/${repo}`);
return {
owner: payload.owner?.login ?? payload.owner?.username ?? owner,
name: payload.name,
fullName: payload.full_name,
defaultBranch: payload.default_branch,
webUrl: payload.html_url,
cloneUrl: payload.clone_url,
archived: payload.archived,
private: payload.private,
empty: payload.empty,
updatedAt: payload.updated_at,
};
}
async getCommit(
owner: string,
repo: string,
+16
View File
@@ -1,3 +1,6 @@
/**
* Gitea API 的前端安全摘要类型,只保存仓库和提交元数据,不包含 token。
*/
export type GiteaBranch = {
name: string;
commitSha: string;
@@ -27,3 +30,16 @@ export type GiteaPullRequestSummary = {
baseRef?: string;
authorName?: string;
};
export type GiteaRepositoryInfo = {
owner: string;
name: string;
fullName?: string;
defaultBranch?: string;
webUrl?: string;
cloneUrl?: string;
archived?: boolean;
private?: boolean;
empty?: boolean;
updatedAt?: string;
};
+32
View File
@@ -1,3 +1,6 @@
/**
* 项目模块对前端暴露的安全模型;第三方 token 只在后端使用,不进入响应。
*/
export type ProjectEnvironmentSummary = {
name: 'test' | 'production';
displayName: string;
@@ -68,3 +71,32 @@ export type ProjectJenkinsDiagnosticsSummary = {
checkedAt: string;
jobs: ProjectJenkinsJobDiagnostic[];
};
export type ProjectGiteaRepositoryDiagnostic = {
projectKey: string;
repositoryUrl: string;
owner?: string;
repo?: string;
status: 'ok' | 'missing' | 'not_configured' | 'unavailable';
message?: string;
defaultBranch?: string;
webUrl?: string;
cloneUrl?: string;
archived?: boolean;
private?: boolean;
empty?: boolean;
branchCount?: number;
tagCount?: number;
latestCommit?: {
sha: string;
message: string;
authorName?: string;
authoredAt?: string;
};
updatedAt?: string;
};
export type ProjectGiteaDiagnosticsSummary = {
checkedAt: string;
repositories: ProjectGiteaRepositoryDiagnostic[];
};
+12
View File
@@ -1,3 +1,6 @@
/**
* 项目接口聚合项目配置、Gitea refs 和 Jenkins/Gitea 诊断结果。
*/
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AuthGuard } from '../auth/auth.guard';
@@ -5,6 +8,7 @@ import { AuthenticatedUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/current-user.decorator';
import { PasswordChangeGuard } from '../auth/password-change.guard';
import {
ProjectGiteaDiagnosticsSummary,
ProjectJenkinsDiagnosticsSummary,
ProjectRefSummary,
ProjectSummary,
@@ -33,6 +37,14 @@ export class ProjectsController {
return this.projectsService.getJenkinsDiagnostics(user);
}
@Get('gitea-diagnostics')
@ApiOkResponse({ description: '查询当前账号可访问项目的 Gitea 仓库可达性。' })
async getGiteaDiagnostics(
@CurrentUser() user: AuthenticatedUser,
): Promise<ProjectGiteaDiagnosticsSummary> {
return this.projectsService.getGiteaDiagnostics(user);
}
@Get(':key')
@ApiOkResponse({ description: '查询项目详情。' })
async getProject(
+152 -1
View File
@@ -1,3 +1,6 @@
/**
* 项目服务统一处理项目权限、Gitea 元数据读取和 Jenkins/Gitea 诊断聚合。
*/
import { Injectable } from '@nestjs/common';
import { AuthService } from '../auth/auth.service';
import { AuthenticatedUser } from '../auth/auth.types';
@@ -11,6 +14,8 @@ import {
import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
import { ProjectRepository } from './project.repository';
import {
ProjectGiteaDiagnosticsSummary,
ProjectGiteaRepositoryDiagnostic,
ProjectJenkinsDiagnosticsSummary,
ProjectJenkinsJobDiagnostic,
ProjectRefSummary,
@@ -135,6 +140,40 @@ export class ProjectsService {
};
}
async getGiteaDiagnostics(
user?: AuthenticatedUser,
): Promise<ProjectGiteaDiagnosticsSummary> {
const projects = await this.listProjects(user);
const health = this.giteaClient.healthSummary();
if (health.status === 'not_configured') {
return {
checkedAt: new Date().toISOString(),
repositories: projects.map((project) => {
const repo = this.parseRepository(project);
return {
projectKey: project.key,
repositoryUrl: project.repositoryUrl,
owner: repo?.owner,
repo: repo?.name,
status: 'not_configured',
message: health.message,
};
}),
};
}
const repositories = await Promise.all(
projects.map((project) => this.getProjectGiteaDiagnostic(project)),
);
return {
checkedAt: new Date().toISOString(),
repositories,
};
}
/**
* Commit 摘要只作为发布前辅助信息;单个摘要读取失败不能影响 refs 主流程。
*/
@@ -246,9 +285,121 @@ export class ProjectsService {
}
}
private async getProjectGiteaDiagnostic(
project: ProjectSummary,
): Promise<ProjectGiteaRepositoryDiagnostic> {
const repo = this.parseRepository(project);
if (!repo) {
return {
projectKey: project.key,
repositoryUrl: project.repositoryUrl,
status: 'unavailable',
message: 'Gitea 仓库地址无法解析',
};
}
try {
const [repository, branches, tags] = await Promise.all([
this.giteaClient.getRepository(repo.owner, repo.name),
this.giteaClient.listBranches(repo.owner, repo.name),
this.giteaClient.listTags(repo.owner, repo.name),
]);
const defaultBranch = repository.defaultBranch ?? project.defaultBranch;
const latestCommit = await this.getRepositoryDefaultCommit(
repo,
defaultBranch,
);
return {
projectKey: project.key,
repositoryUrl: project.repositoryUrl,
owner: repository.owner,
repo: repository.name,
status: 'ok',
message: this.giteaRepositoryMessage(repository),
defaultBranch,
webUrl: repository.webUrl ?? repo.webBaseUrl,
cloneUrl: repository.cloneUrl,
archived: repository.archived,
private: repository.private,
empty: repository.empty,
branchCount: branches.length,
tagCount: tags.length,
latestCommit,
updatedAt: repository.updatedAt,
};
} catch (error) {
if (
error instanceof IntegrationRequestError &&
error.details?.status === 404
) {
return {
projectKey: project.key,
repositoryUrl: project.repositoryUrl,
owner: repo.owner,
repo: repo.name,
status: 'missing',
message: 'Gitea 仓库不存在或当前 token 无权访问',
};
}
return {
projectKey: project.key,
repositoryUrl: project.repositoryUrl,
owner: repo.owner,
repo: repo.name,
status: 'unavailable',
message: error instanceof Error ? error.message : 'Gitea 诊断失败',
};
}
}
/**
* 默认分支 commit 摘要只是展示增强;读取失败不影响仓库可达性诊断。
*/
private async getRepositoryDefaultCommit(
repo: ParsedRepository,
defaultBranch?: string,
): Promise<ProjectGiteaRepositoryDiagnostic['latestCommit']> {
if (!defaultBranch) {
return undefined;
}
try {
return await this.giteaClient.getCommit(
repo.owner,
repo.name,
defaultBranch,
);
} catch {
return undefined;
}
}
private giteaRepositoryMessage(
repository: {
archived?: boolean;
empty?: boolean;
},
): string | undefined {
if (repository.empty) {
return 'Gitea 仓库为空';
}
if (repository.archived) {
return 'Gitea 仓库已归档';
}
return undefined;
}
private fallbackRefs(project: ProjectSummary): ProjectRefSummary {
const branches = Array.from(
new Set([project.defaultBranch, project.key === 'my-resume' ? 'master' : 'develop']),
new Set([
project.defaultBranch,
project.key === 'my-resume' ? 'master' : 'develop',
]),
)
.filter(Boolean)
.map((name) => ({ name }));