464 lines
13 KiB
TypeScript
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;
|
|
}
|