From a6ed51e1074348f6b9ab31a766cac2146a089b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Fri, 12 Jun 2026 04:38:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E9=A1=B9=E7=9B=AE=20?= =?UTF-8?q?Gitea=20=E8=AF=8A=E6=96=AD=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gitea client: 增加仓库元数据读取 - projects: 新增 gitea-diagnostics 聚合接口 - project types: 补齐仓库诊断安全响应模型 --- src/integrations/gitea/gitea.client.ts | 35 ++++++ src/integrations/gitea/gitea.types.ts | 16 +++ src/projects/project.types.ts | 32 ++++++ src/projects/projects.controller.ts | 12 ++ src/projects/projects.service.ts | 153 ++++++++++++++++++++++++- 5 files changed, 247 insertions(+), 1 deletion(-) diff --git a/src/integrations/gitea/gitea.client.ts b/src/integrations/gitea/gitea.client.ts index 71a90ff..c04961d 100644 --- a/src/integrations/gitea/gitea.client.ts +++ b/src/integrations/gitea/gitea.client.ts @@ -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 { + 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, diff --git a/src/integrations/gitea/gitea.types.ts b/src/integrations/gitea/gitea.types.ts index 3c58f73..d7f7d66 100644 --- a/src/integrations/gitea/gitea.types.ts +++ b/src/integrations/gitea/gitea.types.ts @@ -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; +}; diff --git a/src/projects/project.types.ts b/src/projects/project.types.ts index 7efebea..1650070 100644 --- a/src/projects/project.types.ts +++ b/src/projects/project.types.ts @@ -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[]; +}; diff --git a/src/projects/projects.controller.ts b/src/projects/projects.controller.ts index db829c4..36c25da 100644 --- a/src/projects/projects.controller.ts +++ b/src/projects/projects.controller.ts @@ -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 { + return this.projectsService.getGiteaDiagnostics(user); + } + @Get(':key') @ApiOkResponse({ description: '查询项目详情。' }) async getProject( diff --git a/src/projects/projects.service.ts b/src/projects/projects.service.ts index 00317fa..e4005a1 100644 --- a/src/projects/projects.service.ts +++ b/src/projects/projects.service.ts @@ -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 { + 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 { + 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 { + 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 }));