import { BadGatewayException, Injectable, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { CreateGiteaRepoDto, ListGiteaCommitsQuery, ListGiteaReposQuery, ListGiteaTreeQuery, } from './dto'; type GiteaRepo = { id: number; name: string; full_name: string; description: string; private: boolean; html_url: string; clone_url: string; ssh_url: string; default_branch: string; updated_at: string; owner: { login: string }; language?: string; branch_count?: number; release_counter?: number; }; @Injectable() export class GiteaService { private readonly baseUrl: string; private readonly appOrg: string; private readonly token?: string; private readonly releaseBranchPushUsers: string[]; private readonly releaseBranchMergeUsers: string[]; private readonly releaseBranchPushTeams: string[]; private readonly releaseBranchMergeTeams: string[]; constructor(configService: ConfigService) { this.baseUrl = configService .get('GITEA_BASE_URL', 'http://localhost:3000') .replace(/\/$/, ''); this.appOrg = configService.get('GITEA_APP_ORG', 'platform'); this.token = configService.get('GITEA_ADMIN_TOKEN') || undefined; this.releaseBranchPushUsers = parseWhiteList( configService.get('GITEA_RELEASE_PUSH_WHITELIST'), ); this.releaseBranchMergeUsers = parseWhiteList( configService.get('GITEA_RELEASE_MERGE_WHITELIST'), ); this.releaseBranchPushTeams = parseWhiteList( configService.get('GITEA_RELEASE_PUSH_WHITELIST_TEAMS'), ); this.releaseBranchMergeTeams = parseWhiteList( configService.get('GITEA_RELEASE_MERGE_WHITELIST_TEAMS'), ); } async getVersion() { return this.request<{ version: string }>('/api/v1/version'); } async listRepos(query: ListGiteaReposQuery) { const owner = query.owner ?? this.appOrg; if (owner) { const repos = await this.request( `/api/v1/orgs/${encodeURIComponent(owner)}/repos`, ); return { owner, repos: repos .filter((repo) => matchesKeyword(repo, query.keyword)) .map(mapRepo), }; } const params = new URLSearchParams(); if (query.keyword) params.set('q', query.keyword); params.set('page', query.page ?? '1'); params.set('limit', query.limit ?? '30'); const result = await this.request<{ data: GiteaRepo[]; ok: boolean }>( `/api/v1/repos/search?${params.toString()}`, ); return { owner: null, repos: result.data.map(mapRepo), }; } async getRepo(owner: string, repo: string) { const giteaRepo = await this.request( `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, ); return { repo: mapRepo(giteaRepo) }; } async listBranches(owner: string, repo: string) { const branches = await this.request( `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`, ); return { branches }; } async listCommits(owner: string, repo: string, query: ListGiteaCommitsQuery) { const params = new URLSearchParams(); const page = toPositiveInt(query.page, 1); const limit = Math.min(toPositiveInt(query.limit, 20), 100); params.set('limit', String(limit)); params.set('page', String(page)); if (query.sha) { params.set('sha', query.sha); } if (query.since) { params.set('since', query.since); } if (query.until) { params.set('until', query.until); } const { data: commits, total } = await this.requestJsonWithMeta( `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits?${params.toString()}`, ); return { commits, total, page, limit }; } async getCommitDiff(owner: string, repo: string, sha: string) { const encodedOwner = encodeURIComponent(owner); const encodedRepo = encodeURIComponent(repo); const encodedSha = encodeURIComponent(sha); const diff = await this.requestText( `/${encodedOwner}/${encodedRepo}/commit/${encodedSha}.diff`, ); return { diff }; } async listReleases(owner: string, repo: string) { const releases = await this.request( `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`, ); return { releases }; } async listTree(owner: string, repo: string, query: ListGiteaTreeQuery) { const ref = query.ref ?? 'main'; const path = query.path ? `/${query.path.replace(/^\/+/, '')}` : ''; const tree = await this.request( `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents${path}?ref=${encodeURIComponent(ref)}`, ); return { tree }; } async createUserRepo(body: CreateGiteaRepoDto) { this.assertToken(); const repo = await this.request('/api/v1/user/repos', { method: 'POST', body: JSON.stringify(toCreateRepoPayload(body)), }); if (shouldCreateAnyBranch(body)) { await this.createDefaultBranches(repo.owner.login, repo.name, repo.default_branch, body); } await this.setDefaultBranch(repo.owner.login, repo.name, 'develop'); if (body.createReleaseBranch !== false) { await this.protectReleaseBranch(repo.owner.login, repo.name); } return { repo: mapRepo(repo), branches: await this.listBranches(repo.owner.login, repo.name) }; } async createOrgRepo(org: string, body: CreateGiteaRepoDto) { this.assertToken(); const repo = await this.request(`/api/v1/orgs/${encodeURIComponent(org)}/repos`, { method: 'POST', body: JSON.stringify(toCreateRepoPayload(body)), }); if (shouldCreateAnyBranch(body)) { await this.createDefaultBranches(org, repo.name, repo.default_branch, body); } await this.setDefaultBranch(org, repo.name, 'develop'); if (body.createReleaseBranch !== false) { await this.protectReleaseBranch(org, repo.name); } return { repo: mapRepo(repo), branches: await this.listBranches(org, repo.name) }; } private async setDefaultBranch(owner: string, repo: string, branch: string) { const branches = await this.listBranches(owner, repo); const hasBranch = Array.isArray(branches.branches) && branches.branches.some((item) => { return typeof item === 'object' && item && 'name' in item && item.name === branch; }); if (!hasBranch) { return; } await this.request(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, { method: 'PATCH', body: JSON.stringify({ default_branch: branch }), }); } async createUserSshKey(username: string, body: { title: string; key: string }) { this.assertToken(); return this.request<{ id: number }>( `/api/v1/admin/users/${encodeURIComponent(username)}/keys`, { method: 'POST', body: JSON.stringify({ title: body.title, key: body.key, read_only: false, }), }, ); } async deleteUserSshKey(username: string, keyId: number) { this.assertToken(); await this.request( `/api/v1/admin/users/${encodeURIComponent(username)}/keys/${keyId}`, { method: 'DELETE', allowedStatuses: [404], }, ); } private async createDefaultBranches( owner: string, repo: string, baseBranch: string, body: CreateGiteaRepoDto, ) { const createdBranches = []; const branches = [ body.createDevelopBranch !== false ? 'develop' : null, body.createReleaseBranch !== false ? 'release' : null, ].filter((branch): branch is string => Boolean(branch)); for (const branch of branches) { try { const created = await this.request( `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`, { method: 'POST', body: JSON.stringify({ new_branch_name: branch, old_branch_name: baseBranch, }), allowedStatuses: [409], }, ); if (created) { createdBranches.push(created); } } catch (error) { throw error; } } return createdBranches; } private async protectReleaseBranch(owner: string, repo: string) { const pushUsernames = uniqueUsers(this.releaseBranchPushUsers); const mergeUsernames = uniqueUsers(this.releaseBranchMergeUsers); const pushTeams = uniqueUsers(this.releaseBranchPushTeams); const mergeTeams = uniqueUsers(this.releaseBranchMergeTeams); const hasPushWhitelist = pushUsernames.length > 0 || pushTeams.length > 0; const hasMergeWhitelist = mergeUsernames.length > 0 || mergeTeams.length > 0; const body = { branch_name: 'release', enable_push: true, enable_push_whitelist: hasPushWhitelist, push_whitelist_usernames: pushUsernames, push_whitelist_teams: pushTeams, enable_merge_whitelist: hasMergeWhitelist, merge_whitelist_usernames: hasMergeWhitelist ? mergeUsernames : [], merge_whitelist_teams: hasMergeWhitelist ? mergeTeams : [], enable_force_push: false, }; await this.request(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branch_protections`, { method: 'POST', body: JSON.stringify(body), allowedStatuses: [409], }); } private async request( path: string, init?: RequestInit & { allowedStatuses?: number[] }, ): Promise { const response = await fetch(`${this.baseUrl}${path}`, { ...init, headers: { Accept: 'application/json', ...(init?.body ? { 'Content-Type': 'application/json' } : {}), ...(this.token ? { Authorization: `token ${this.token}` } : {}), ...init?.headers, }, }); if (init?.allowedStatuses?.includes(response.status)) { return null as T; } if (!response.ok) { const message = await response.text(); throw new BadGatewayException({ message: 'Gitea API 요청이 실패했습니다.', status: response.status, detail: message, }); } if (response.status === 204) { return null as T; } return (await response.json()) as T; } private async requestJsonWithMeta( path: string, init?: RequestInit, ): Promise<{ data: T; total: number | null }> { const response = await fetch(`${this.baseUrl}${path}`, { ...init, headers: { Accept: 'application/json', ...(this.token ? { Authorization: `token ${this.token}` } : {}), ...init?.headers, }, }); if (!response.ok) { const message = await response.text(); throw new BadGatewayException({ message: 'Gitea API 요청이 실패했습니다.', status: response.status, detail: message, }); } const totalHeader = response.headers.get('x-total-count'); const total = totalHeader ? Number(totalHeader) : null; return { data: (await response.json()) as T, total: Number.isFinite(total) ? total : null, }; } private async requestText(path: string): Promise { const response = await fetch(`${this.baseUrl}${path}`, { headers: { Accept: 'text/plain', ...(this.token ? { Authorization: `token ${this.token}` } : {}), }, }); if (!response.ok) { const message = await response.text(); throw new BadGatewayException({ message: 'Gitea API 요청이 실패했습니다.', status: response.status, detail: message, }); } return response.text(); } private assertToken() { if (!this.token) { throw new UnauthorizedException( 'GITEA_ADMIN_TOKEN 설정이 필요합니다.', ); } } } function shouldCreateAnyBranch(body: CreateGiteaRepoDto) { if (body.createDefaultBranches === false) { return false; } return body.createDevelopBranch !== false || body.createReleaseBranch !== false; } function matchesKeyword(repo: GiteaRepo, keyword?: string) { if (!keyword?.trim()) { return true; } const normalizedKeyword = keyword.trim().toLowerCase(); return [repo.name, repo.full_name, repo.description, repo.language] .filter((value): value is string => Boolean(value)) .some((value) => value.toLowerCase().includes(normalizedKeyword)); } function mapRepo(repo: GiteaRepo) { return { id: repo.id, owner: repo.owner.login, name: repo.name, fullName: repo.full_name, description: repo.description, private: repo.private, htmlUrl: repo.html_url, cloneUrl: repo.clone_url, sshUrl: repo.ssh_url, defaultBranch: repo.default_branch, updatedAt: repo.updated_at, language: repo.language, branchCount: repo.branch_count, releaseCount: repo.release_counter, }; } function parseWhiteList(raw?: string) { if (!raw) { return []; } return raw .split(',') .map((value) => value.trim()) .filter((value) => value.length > 0); } function uniqueUsers(values: string[]) { const seen = new Set(); const next: string[] = []; for (const value of values) { if (seen.has(value)) continue; seen.add(value); next.push(value); } return next; } function toCreateRepoPayload(body: CreateGiteaRepoDto) { return { name: body.name, description: body.description, private: body.private ?? true, auto_init: body.autoInit ?? true, }; } function toPositiveInt(value: string | undefined, fallback: number) { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; }