From fb736701ca355b9bcff59349dbaad4a30035c08f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Fri, 12 Jun 2026 02:12:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A5=E9=BD=90=20Gitea=20PR=20?= =?UTF-8?q?=E6=9D=A5=E6=BA=90=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/integrations/gitea/gitea.client.ts | 67 +++++++++++++++++++++++++- src/integrations/gitea/gitea.types.ts | 13 +++++ src/projects/project.types.ts | 48 ++++++++++-------- src/projects/projects.service.spec.ts | 33 ++++++++++++- src/projects/projects.service.ts | 52 ++++++++++++++++---- 5 files changed, 182 insertions(+), 31 deletions(-) diff --git a/src/integrations/gitea/gitea.client.ts b/src/integrations/gitea/gitea.client.ts index 33f1f75..71a90ff 100644 --- a/src/integrations/gitea/gitea.client.ts +++ b/src/integrations/gitea/gitea.client.ts @@ -3,7 +3,12 @@ import { ConfigService } from '@nestjs/config'; import { IntegrationRequestError, IntegrationUnavailableError } from '../../common/errors/integration-error'; import { redactSensitive } from '../../common/security/redact-sensitive'; import { EnvConfig } from '../../config/env.schema'; -import { GiteaBranch, GiteaCommitSummary, GiteaTag } from './gitea.types'; +import { + GiteaBranch, + GiteaCommitSummary, + GiteaPullRequestSummary, + GiteaTag, +} from './gitea.types'; @Injectable() export class GiteaClient { @@ -62,6 +67,66 @@ export class GiteaClient { }; } + async getCommitPullRequest( + owner: string, + repo: string, + sha: string, + ): Promise { + const payload = await this.optionalRequest<{ + number: number; + title: string; + state: string; + merged?: boolean; + merged_at?: string; + merge_commit_sha?: string; + head?: { + ref?: string; + sha?: string; + }; + base?: { + ref?: string; + }; + user?: { + login?: string; + }; + }>(`/api/v1/repos/${owner}/${repo}/commits/${encodeURIComponent(sha)}/pull`); + + if (!payload) { + return undefined; + } + + return { + number: payload.number, + title: payload.title, + state: payload.state, + merged: payload.merged === true, + mergedAt: payload.merged_at, + mergeCommitSha: payload.merge_commit_sha, + headRef: payload.head?.ref, + headSha: payload.head?.sha, + baseRef: payload.base?.ref, + authorName: payload.user?.login, + }; + } + + private async optionalRequest( + path: string, + init: RequestInit = {}, + ): Promise { + try { + return await this.request(path, init); + } catch (error) { + if ( + error instanceof IntegrationRequestError && + error.details?.status === 404 + ) { + return undefined; + } + + throw error; + } + } + private async request(path: string, init: RequestInit = {}): Promise { this.assertConfigured(); const baseUrl = this.config.get('GITEA_BASE_URL', { infer: true }) ?? ''; diff --git a/src/integrations/gitea/gitea.types.ts b/src/integrations/gitea/gitea.types.ts index 859dac8..3c58f73 100644 --- a/src/integrations/gitea/gitea.types.ts +++ b/src/integrations/gitea/gitea.types.ts @@ -14,3 +14,16 @@ export type GiteaCommitSummary = { authorName?: string; authoredAt?: string; }; + +export type GiteaPullRequestSummary = { + number: number; + title: string; + state: string; + merged: boolean; + mergedAt?: string; + mergeCommitSha?: string; + headRef?: string; + headSha?: string; + baseRef?: string; + authorName?: string; +}; diff --git a/src/projects/project.types.ts b/src/projects/project.types.ts index 760a26b..1a778ca 100644 --- a/src/projects/project.types.ts +++ b/src/projects/project.types.ts @@ -16,26 +16,34 @@ export type ProjectSummary = { environments: ProjectEnvironmentSummary[]; }; +export type ProjectPullRequestSummary = { + number: number; + title: string; + state: string; + merged: boolean; + mergedAt?: string; + mergeCommitSha?: string; + headRef?: string; + headSha?: string; + baseRef?: string; + authorName?: string; + url?: string; +}; + +export type ProjectRefItemSummary = { + name: string; + commitSha?: string; + commit?: { + sha: string; + message: string; + authorName?: string; + authoredAt?: string; + }; + pullRequest?: ProjectPullRequestSummary; +}; + export type ProjectRefSummary = { source: 'gitea' | 'fallback'; - branches: Array<{ - name: string; - commitSha?: string; - commit?: { - sha: string; - message: string; - authorName?: string; - authoredAt?: string; - }; - }>; - tags: Array<{ - name: string; - commitSha?: string; - commit?: { - sha: string; - message: string; - authorName?: string; - authoredAt?: string; - }; - }>; + branches: ProjectRefItemSummary[]; + tags: ProjectRefItemSummary[]; }; diff --git a/src/projects/projects.service.spec.ts b/src/projects/projects.service.spec.ts index 964530e..ab2a34e 100644 --- a/src/projects/projects.service.spec.ts +++ b/src/projects/projects.service.spec.ts @@ -21,6 +21,15 @@ describe('ProjectsService', () => { branches?: Array<{ name: string; commitSha: string }>; tags?: Array<{ name: string; commitSha: string }>; commits?: Record; + pullRequests?: Record< + string, + { + number: number; + title: string; + state: string; + merged: boolean; + } + >; }) { const repository = { findMany: jest.fn(() => Promise.resolve([project])), @@ -46,9 +55,16 @@ describe('ProjectsService', () => { }, ), ), + getCommitPullRequest: jest.fn((owner: string, repo: string, sha: string) => + Promise.resolve(options?.pullRequests?.[sha]), + ), } satisfies Pick< GiteaClient, - 'healthSummary' | 'listBranches' | 'listTags' | 'getCommit' + | 'healthSummary' + | 'listBranches' + | 'listTags' + | 'getCommit' + | 'getCommitPullRequest' >; const auth = { listAllowedProjectKeys: jest.fn(() => Promise.resolve([project.key])), @@ -90,6 +106,14 @@ describe('ProjectsService', () => { abc123: { sha: 'abc123', message: 'feat: add platform refs' }, def456: { sha: 'def456', message: 'chore: tag release' }, }, + pullRequests: { + abc123: { + number: 2, + title: 'feat: add platform refs', + state: 'closed', + merged: true, + }, + }, }); const refs = await service.listProjectRefs(project.key); @@ -101,6 +125,13 @@ describe('ProjectsService', () => { name: 'feature/devops-platform', commitSha: 'abc123', commit: { sha: 'abc123', message: 'feat: add platform refs' }, + pullRequest: { + number: 2, + title: 'feat: add platform refs', + state: 'closed', + merged: true, + url: 'https://gitea.ops.mrzhan.top/my-project/access-manage/pulls/2', + }, }, ], tags: [ diff --git a/src/projects/projects.service.ts b/src/projects/projects.service.ts index 7f5bcdf..2ab6775 100644 --- a/src/projects/projects.service.ts +++ b/src/projects/projects.service.ts @@ -3,10 +3,19 @@ import { AuthService } from '../auth/auth.service'; import { AuthenticatedUser } from '../auth/auth.types'; import { AppError } from '../common/errors/app-error'; import { GiteaClient } from '../integrations/gitea/gitea.client'; -import { GiteaCommitSummary } from '../integrations/gitea/gitea.types'; +import { + GiteaCommitSummary, + GiteaPullRequestSummary, +} from '../integrations/gitea/gitea.types'; import { ProjectRepository } from './project.repository'; import { ProjectRefSummary, ProjectSummary } from './project.types'; +type ParsedRepository = { + owner: string; + name: string; + webBaseUrl: string; +}; + @Injectable() export class ProjectsService { constructor( @@ -83,9 +92,16 @@ export class ProjectsService { * Commit 摘要只作为发布前辅助信息;单个摘要读取失败不能影响 refs 主流程。 */ private async enrichRefsWithCommit( - repo: { owner: string; name: string }, + repo: ParsedRepository, refs: T[], - ): Promise> { + ): Promise< + Array< + T & { + commit?: GiteaCommitSummary; + pullRequest?: GiteaPullRequestSummary & { url?: string }; + } + > + > { return Promise.all( refs.map(async (ref) => { if (!ref.commitSha) { @@ -93,13 +109,24 @@ export class ProjectsService { } try { - return { - ...ref, - commit: await this.giteaClient.getCommit( + const [commit, pullRequest] = await Promise.all([ + this.giteaClient.getCommit(repo.owner, repo.name, ref.commitSha), + this.giteaClient.getCommitPullRequest( repo.owner, repo.name, ref.commitSha, ), + ]); + + return { + ...ref, + commit, + pullRequest: pullRequest + ? { + ...pullRequest, + url: `${repo.webBaseUrl}/${repo.owner}/${repo.name}/pulls/${pullRequest.number}`, + } + : undefined, }; } catch { return ref; @@ -110,14 +137,21 @@ export class ProjectsService { private parseRepository( project: ProjectSummary, - ): { owner: string; name: string } | null { + ): ParsedRepository | null { try { - const pathname = new URL(project.repositoryUrl).pathname.replace(/\.git$/, ''); + const url = new URL(project.repositoryUrl); + const pathname = url.pathname.replace(/\.git$/, ''); const parts = pathname.split('/').filter(Boolean); const name = parts.at(-1); const owner = parts.at(-2); - return owner && name ? { owner, name } : null; + return owner && name + ? { + owner, + name, + webBaseUrl: url.origin, + } + : null; } catch { return null; }