Files
studio-admin-be/src/gitea/gitea.service.ts
T
chyoo 36acfedaee
ci/woodpecker/push/woodpecker Pipeline was successful
paginate app operations and refine runtime settings layout
2026-07-07 17:42:31 +09:00

464 lines
13 KiB
TypeScript

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<string>('GITEA_BASE_URL', 'http://localhost:3000')
.replace(/\/$/, '');
this.appOrg = configService.get<string>('GITEA_APP_ORG', 'platform');
this.token = configService.get<string>('GITEA_ADMIN_TOKEN') || undefined;
this.releaseBranchPushUsers = parseWhiteList(
configService.get<string>('GITEA_RELEASE_PUSH_WHITELIST'),
);
this.releaseBranchMergeUsers = parseWhiteList(
configService.get<string>('GITEA_RELEASE_MERGE_WHITELIST'),
);
this.releaseBranchPushTeams = parseWhiteList(
configService.get<string>('GITEA_RELEASE_PUSH_WHITELIST_TEAMS'),
);
this.releaseBranchMergeTeams = parseWhiteList(
configService.get<string>('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<GiteaRepo[]>(
`/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<GiteaRepo>(
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
);
return { repo: mapRepo(giteaRepo) };
}
async listBranches(owner: string, repo: string) {
const branches = await this.request<unknown[]>(
`/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<unknown[]>(
`/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<unknown[]>(
`/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<unknown>(
`/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<GiteaRepo>('/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);
}
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<GiteaRepo>(`/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);
}
if (body.createReleaseBranch !== false) {
await this.protectReleaseBranch(org, repo.name);
}
return { repo: mapRepo(repo), branches: await this.listBranches(org, repo.name) };
}
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<T>(
path: string,
init?: RequestInit & { allowedStatuses?: number[] },
): Promise<T> {
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<T>(
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<string> {
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<string>();
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;
}