init studio admin backend

This commit is contained in:
2026-07-01 17:30:04 +09:00
commit 4437d9980d
144 changed files with 44964 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
import { IsBoolean, IsInt, IsOptional, IsString, Min } from 'class-validator';
export class ListGiteaReposQuery {
@IsOptional()
@IsString()
keyword?: string;
@IsOptional()
@IsString()
owner?: string;
@IsOptional()
@IsString()
page?: string;
@IsOptional()
@IsString()
limit?: string;
}
export class ListGiteaCommitsQuery {
@IsOptional()
@IsString()
sha?: string;
@IsOptional()
@IsString()
limit?: string;
}
export class ListGiteaTreeQuery {
@IsOptional()
@IsString()
ref?: string;
@IsOptional()
@IsString()
path?: string;
}
export class CreateGiteaRepoDto {
@IsString()
name!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsBoolean()
private?: boolean;
@IsOptional()
@IsBoolean()
autoInit?: boolean;
@IsOptional()
@IsBoolean()
createDefaultBranches?: boolean;
@IsOptional()
@IsBoolean()
createDevelopBranch?: boolean;
@IsOptional()
@IsBoolean()
createReleaseBranch?: boolean;
}
export class LinkComponentSourceDto {
@IsString()
repository!: string;
@IsOptional()
@IsString()
branch?: string;
@IsOptional()
@IsString()
buildTarget?: string;
}
+67
View File
@@ -0,0 +1,67 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import {
CreateGiteaRepoDto,
ListGiteaCommitsQuery,
ListGiteaReposQuery,
ListGiteaTreeQuery,
} from './dto';
import { GiteaService } from './gitea.service';
@Controller('gitea')
export class GiteaController {
constructor(private readonly giteaService: GiteaService) {}
@Get('version')
getVersion() {
return this.giteaService.getVersion();
}
@Get('repos')
listRepos(@Query() query: ListGiteaReposQuery) {
return this.giteaService.listRepos(query);
}
@Post('repos')
createUserRepo(@Body() body: CreateGiteaRepoDto) {
return this.giteaService.createUserRepo(body);
}
@Post('orgs/:org/repos')
createOrgRepo(@Param('org') org: string, @Body() body: CreateGiteaRepoDto) {
return this.giteaService.createOrgRepo(org, body);
}
@Get('repos/:owner/:repo')
getRepo(@Param('owner') owner: string, @Param('repo') repo: string) {
return this.giteaService.getRepo(owner, repo);
}
@Get('repos/:owner/:repo/branches')
listBranches(@Param('owner') owner: string, @Param('repo') repo: string) {
return this.giteaService.listBranches(owner, repo);
}
@Get('repos/:owner/:repo/commits')
listCommits(
@Param('owner') owner: string,
@Param('repo') repo: string,
@Query() query: ListGiteaCommitsQuery,
) {
return this.giteaService.listCommits(owner, repo, query);
}
@Get('repos/:owner/:repo/releases')
listReleases(@Param('owner') owner: string, @Param('repo') repo: string) {
return this.giteaService.listReleases(owner, repo);
}
@Get('repos/:owner/:repo/tree')
listTree(
@Param('owner') owner: string,
@Param('repo') repo: string,
@Query() query: ListGiteaTreeQuery,
) {
return this.giteaService.listTree(owner, repo, query);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { GiteaController } from './gitea.controller';
import { GiteaService } from './gitea.service';
@Module({
controllers: [GiteaController],
providers: [GiteaService],
exports: [GiteaService],
})
export class GiteaModule {}
+312
View File
@@ -0,0 +1,312 @@
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;
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;
}
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();
params.set('limit', query.limit ?? '20');
if (query.sha) {
params.set('sha', query.sha);
}
const commits = await this.request<unknown[]>(
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits?${params.toString()}`,
);
return { commits };
}
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);
}
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);
}
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 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 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 toCreateRepoPayload(body: CreateGiteaRepoDto) {
return {
name: body.name,
description: body.description,
private: body.private ?? true,
auto_init: body.autoInit ?? true,
};
}