init studio admin backend
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user