feat: 저장소 생성 시 Gitea 조직 연동 추가
외부 Gitea 인스턴스의 조직에 저장소를 자동 동기화한다. - GiteaService: /api/v1 클라이언트(내장 fetch, 새 의존성 없음), enabled 게이트 - Repo 엔티티에 연동 컬럼(giteaRepoId/giteaFullName/cloneUrl/htmlUrl) 추가 - RepoService 생성/수정/삭제에 Gitea 동기화 와이어링(생성 우선·실패 시 롤백) - GITEA_* env 문서화(.env.example), api-contract/진행현황 문서 갱신 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GiteaService } from './gitea.service';
|
||||
|
||||
// Gitea 연동 모듈 — 외부 Gitea 인스턴스의 조직 저장소를 생성/수정/삭제하는 클라이언트 제공
|
||||
@Module({
|
||||
providers: [GiteaService],
|
||||
exports: [GiteaService],
|
||||
})
|
||||
export class GiteaModule {}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { GiteaApiError, GiteaService } from './gitea.service';
|
||||
|
||||
// ConfigService 간이 스텁 — 주어진 맵에서만 값을 반환
|
||||
function configStub(values: Record<string, string>): ConfigService {
|
||||
return {
|
||||
get: (key: string): string | undefined => values[key],
|
||||
} as unknown as ConfigService;
|
||||
}
|
||||
|
||||
// fetch 응답 간이 모킹
|
||||
function mockResponse(status: number, body: unknown): Promise<Response> {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(typeof body === 'string' ? body : ''),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
describe('GiteaService', () => {
|
||||
const fullConfig = {
|
||||
GITEA_BASE_URL: 'https://gitea.example.com/', // 끝 슬래시 포함 — 제거되어야 함
|
||||
GITEA_TOKEN: 'secret-token',
|
||||
GITEA_ORG: 'marketing-team',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('enabled', () => {
|
||||
it('필수 env 가 모두 있으면 활성화', () => {
|
||||
const svc = new GiteaService(configStub(fullConfig));
|
||||
expect(svc.enabled).toBe(true);
|
||||
expect(svc.org).toBe('marketing-team');
|
||||
});
|
||||
|
||||
it('하나라도 비면 비활성화', () => {
|
||||
const svc = new GiteaService(
|
||||
configStub({ GITEA_BASE_URL: 'https://gitea.example.com' }),
|
||||
);
|
||||
expect(svc.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRepo', () => {
|
||||
it('조직 경로로 POST 하고 응답을 매핑한다(베이스 URL 끝 슬래시 제거)', async () => {
|
||||
const res = mockResponse(201, {
|
||||
id: 42,
|
||||
full_name: 'marketing-team/q3-launch',
|
||||
clone_url: 'https://gitea.example.com/marketing-team/q3-launch.git',
|
||||
html_url: 'https://gitea.example.com/marketing-team/q3-launch',
|
||||
default_branch: 'main',
|
||||
});
|
||||
const fetchMock = jest.spyOn(global, 'fetch').mockReturnValue(res);
|
||||
|
||||
const svc = new GiteaService(configStub(fullConfig));
|
||||
const result = await svc.createRepo({
|
||||
name: 'q3-launch',
|
||||
description: '런칭',
|
||||
private: true,
|
||||
defaultBranch: 'main',
|
||||
});
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe(
|
||||
'https://gitea.example.com/api/v1/orgs/marketing-team/repos',
|
||||
);
|
||||
expect(init?.method).toBe('POST');
|
||||
expect(JSON.parse(init?.body as string)).toMatchObject({
|
||||
name: 'q3-launch',
|
||||
description: '런칭',
|
||||
private: true,
|
||||
default_branch: 'main',
|
||||
auto_init: true,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: 42,
|
||||
fullName: 'marketing-team/q3-launch',
|
||||
cloneUrl: 'https://gitea.example.com/marketing-team/q3-launch.git',
|
||||
htmlUrl: 'https://gitea.example.com/marketing-team/q3-launch',
|
||||
defaultBranch: 'main',
|
||||
});
|
||||
});
|
||||
|
||||
it('실패 응답은 상태코드를 보존한 GiteaApiError 로 던진다', async () => {
|
||||
jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockReturnValue(mockResponse(409, 'conflict'));
|
||||
const svc = new GiteaService(configStub(fullConfig));
|
||||
|
||||
const call = () =>
|
||||
svc.createRepo({
|
||||
name: 'dup',
|
||||
description: '',
|
||||
private: false,
|
||||
defaultBranch: 'main',
|
||||
});
|
||||
|
||||
await expect(call()).rejects.toBeInstanceOf(GiteaApiError);
|
||||
await expect(call()).rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteRepo', () => {
|
||||
it('repo 경로로 DELETE 한다', async () => {
|
||||
const res = mockResponse(204, '');
|
||||
const fetchMock = jest.spyOn(global, 'fetch').mockReturnValue(res);
|
||||
const svc = new GiteaService(configStub(fullConfig));
|
||||
|
||||
await svc.deleteRepo('q3-launch');
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe(
|
||||
'https://gitea.example.com/api/v1/repos/marketing-team/q3-launch',
|
||||
);
|
||||
expect(init?.method).toBe('DELETE');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
// Gitea 저장소 생성/조회 결과(우리가 보관하는 핵심 필드만 추림)
|
||||
export interface GiteaRepoResult {
|
||||
id: number;
|
||||
fullName: string; // 'org/slug'
|
||||
cloneUrl: string; // HTTPS clone URL
|
||||
htmlUrl: string; // 웹 UI URL
|
||||
defaultBranch: string;
|
||||
}
|
||||
|
||||
// Gitea API 호출 실패 — 상태 코드를 보존해 호출부에서 분기(예: 409 → 이름 충돌)
|
||||
export class GiteaApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'GiteaApiError';
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea REST API(/api/v1) 응답 중 사용하는 필드만 정의
|
||||
interface GiteaRepoApi {
|
||||
id: number;
|
||||
full_name: string;
|
||||
clone_url: string;
|
||||
html_url: string;
|
||||
default_branch: string;
|
||||
}
|
||||
|
||||
// 외부 Gitea 인스턴스와 통신하는 얇은 API 클라이언트.
|
||||
// 조직(GITEA_ORG) 아래 저장소를 생성/수정/삭제한다.
|
||||
// 필수 env(GITEA_BASE_URL/GITEA_TOKEN/GITEA_ORG)가 비어 있으면 비활성화 상태가 되어,
|
||||
// 저장소 CRUD 는 Gitea 호출 없이 기존과 동일하게 동작한다(로컬/미연동 환경 대비).
|
||||
@Injectable()
|
||||
export class GiteaService {
|
||||
private readonly logger = new Logger(GiteaService.name);
|
||||
private readonly baseUrl: string; // 끝 슬래시 제거된 형태
|
||||
private readonly token: string;
|
||||
readonly org: string;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.baseUrl = (config.get<string>('GITEA_BASE_URL') ?? '').replace(
|
||||
/\/+$/,
|
||||
'',
|
||||
);
|
||||
this.token = config.get<string>('GITEA_TOKEN') ?? '';
|
||||
this.org = config.get<string>('GITEA_ORG') ?? '';
|
||||
|
||||
if (this.enabled) {
|
||||
this.logger.log(`Gitea 연동 활성화 — ${this.baseUrl} (org: ${this.org})`);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
'Gitea 연동 비활성화 — GITEA_BASE_URL/GITEA_TOKEN/GITEA_ORG 미설정. 저장소는 Gitea 동기화 없이 동작합니다.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 필수 설정이 모두 있으면 연동 활성화
|
||||
get enabled(): boolean {
|
||||
return Boolean(this.baseUrl && this.token && this.org);
|
||||
}
|
||||
|
||||
// 조직 아래 저장소 생성 (초기 커밋/기본 브랜치 포함)
|
||||
async createRepo(input: {
|
||||
name: string; // = Relay slugName
|
||||
description: string;
|
||||
private: boolean;
|
||||
defaultBranch: string;
|
||||
}): Promise<GiteaRepoResult> {
|
||||
const data = await this.request<GiteaRepoApi>(
|
||||
'POST',
|
||||
`/orgs/${encodeURIComponent(this.org)}/repos`,
|
||||
{
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
private: input.private,
|
||||
default_branch: input.defaultBranch,
|
||||
auto_init: true, // 초기 커밋 + 기본 브랜치 생성
|
||||
},
|
||||
);
|
||||
return this.toResult(data);
|
||||
}
|
||||
|
||||
// 저장소 메타데이터(설명/공개범위) 수정 — slug(name) 는 변경하지 않음
|
||||
async updateRepo(
|
||||
name: string,
|
||||
patch: { description?: string; private?: boolean },
|
||||
): Promise<void> {
|
||||
await this.request<GiteaRepoApi>(
|
||||
'PATCH',
|
||||
`/repos/${encodeURIComponent(this.org)}/${encodeURIComponent(name)}`,
|
||||
{
|
||||
description: patch.description,
|
||||
private: patch.private,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 저장소 삭제
|
||||
async deleteRepo(name: string): Promise<void> {
|
||||
await this.request<null>(
|
||||
'DELETE',
|
||||
`/repos/${encodeURIComponent(this.org)}/${encodeURIComponent(name)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- 내부 헬퍼 ---
|
||||
|
||||
// Gitea API 공통 요청 — 토큰 헤더 부착, 실패 시 GiteaApiError
|
||||
private async request<T>(
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
||||
path: string,
|
||||
body?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `token ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => '');
|
||||
throw new GiteaApiError(
|
||||
res.status,
|
||||
`Gitea ${method} ${path} 실패 → ${res.status} ${detail}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 204 No Content(삭제 등) 또는 빈 본문 대응
|
||||
if (res.status === 204) {
|
||||
return null as T;
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// API 응답 → 보관용 결과 매핑
|
||||
private toResult(data: GiteaRepoApi): GiteaRepoResult {
|
||||
return {
|
||||
id: data.id,
|
||||
fullName: data.full_name,
|
||||
cloneUrl: data.clone_url,
|
||||
htmlUrl: data.html_url,
|
||||
defaultBranch: data.default_branch,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,24 @@ export class Repo {
|
||||
@Column({ default: 'main' })
|
||||
branch!: string;
|
||||
|
||||
// --- Gitea 연동 정보 (연동 비활성/생성 실패 시 null) ---
|
||||
|
||||
// Gitea 저장소 내부 ID
|
||||
@Column({ name: 'gitea_repo_id', type: 'int', nullable: true })
|
||||
giteaRepoId!: number | null;
|
||||
|
||||
// Gitea 저장소 전체 이름 (예: 'marketing-team/q3-launch-campaign')
|
||||
@Column({ name: 'gitea_full_name', type: 'varchar', nullable: true })
|
||||
giteaFullName!: string | null;
|
||||
|
||||
// HTTPS clone URL
|
||||
@Column({ name: 'clone_url', type: 'varchar', nullable: true })
|
||||
cloneUrl!: string | null;
|
||||
|
||||
// Gitea 웹 UI URL
|
||||
@Column({ name: 'html_url', type: 'varchar', nullable: true })
|
||||
htmlUrl!: string | null;
|
||||
|
||||
// 멤버 목록
|
||||
@OneToMany(() => RepoMember, (member) => member.repo)
|
||||
members!: Relation<RepoMember>[];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { GiteaModule } from '../gitea/gitea.module';
|
||||
import { Repo } from './entities/repo.entity';
|
||||
import { RepoMember } from './entities/repo-member.entity';
|
||||
import { RepoService } from './repo.service';
|
||||
@@ -8,7 +9,11 @@ import { RepoController } from './repo.controller';
|
||||
|
||||
// 저장소 모듈
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Repo, RepoMember]), UserModule],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Repo, RepoMember]),
|
||||
UserModule,
|
||||
GiteaModule,
|
||||
],
|
||||
controllers: [RepoController],
|
||||
providers: [RepoService],
|
||||
exports: [RepoService],
|
||||
|
||||
@@ -2,20 +2,29 @@ import {
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import {
|
||||
GiteaApiError,
|
||||
GiteaService,
|
||||
type GiteaRepoResult,
|
||||
} from '../gitea/gitea.service';
|
||||
import { Repo, type RepoVisibility } from './entities/repo.entity';
|
||||
import { RepoMember } from './entities/repo-member.entity';
|
||||
import { CreateRepoDto } from './dto/create-repo.dto';
|
||||
import { UpdateRepoDto } from './dto/update-repo.dto';
|
||||
|
||||
// 현재는 단일 조직 고정 (조직 모델 도입 시 교체)
|
||||
// Gitea 연동 비활성 시 사용할 기본 소유자 (조직 모델 도입 시 교체)
|
||||
const DEFAULT_OWNER = 'marketing-team';
|
||||
|
||||
// Gitea 저장소 이름 충돌 시 반환되는 HTTP 상태 (enum 비교 회피용 숫자 상수)
|
||||
const HTTP_CONFLICT = 409;
|
||||
|
||||
// 저장소 응답 형태 — 파생 라벨/진행률은 프론트에서 계산하므로 원시값 위주로 내린다
|
||||
export interface RepoResponse {
|
||||
id: string; // slugName (라우팅 식별자)
|
||||
@@ -29,18 +38,23 @@ export interface RepoResponse {
|
||||
memberCount: number;
|
||||
doneCount: number;
|
||||
totalCount: number;
|
||||
cloneUrl: string | null; // Gitea HTTPS clone URL (미연동 시 null)
|
||||
htmlUrl: string | null; // Gitea 웹 UI URL (미연동 시 null)
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 저장소 비즈니스 로직
|
||||
@Injectable()
|
||||
export class RepoService {
|
||||
private readonly logger = new Logger(RepoService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Repo)
|
||||
private readonly repoRepo: Repository<Repo>,
|
||||
@InjectRepository(RepoMember)
|
||||
private readonly memberRepo: Repository<RepoMember>,
|
||||
private readonly userService: UserService,
|
||||
private readonly gitea: GiteaService,
|
||||
) {}
|
||||
|
||||
// 내가 멤버인 저장소 목록
|
||||
@@ -73,7 +87,7 @@ export class RepoService {
|
||||
return this.toResponse(repo);
|
||||
}
|
||||
|
||||
// 저장소 생성 — 생성자를 소유자(admin)로 등록
|
||||
// 저장소 생성 — Gitea 조직에 실제 repo 를 만들고, 생성자를 소유자(admin)로 등록
|
||||
async create(dto: CreateRepoDto, userId: string): Promise<RepoResponse> {
|
||||
const existing = await this.repoRepo.findOne({
|
||||
where: { slugName: dto.slug },
|
||||
@@ -90,26 +104,61 @@ export class RepoService {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
const repo = this.repoRepo.create({
|
||||
slugName: dto.slug,
|
||||
owner: DEFAULT_OWNER,
|
||||
name: dto.name,
|
||||
description: dto.description ?? null,
|
||||
visibility: dto.visibility,
|
||||
branch: dto.branch?.trim() || 'main',
|
||||
});
|
||||
const saved = await this.repoRepo.save(repo);
|
||||
const description = dto.description ?? null;
|
||||
const visibility = dto.visibility;
|
||||
const branch = dto.branch?.trim() || 'main';
|
||||
// 연동 시 소유자는 Gitea 조직명 — slug `owner/slugName` 이 Gitea full_name 과 일치
|
||||
const owner = this.gitea.enabled ? this.gitea.org : DEFAULT_OWNER;
|
||||
|
||||
const owner = this.memberRepo.create({
|
||||
repo: saved,
|
||||
user,
|
||||
roleType: 'admin',
|
||||
isOwner: true,
|
||||
subRole: '리드',
|
||||
});
|
||||
await this.memberRepo.save(owner);
|
||||
// 1) Gitea 조직에 실제 저장소 생성 (연동 활성 시)
|
||||
let giteaRepo: GiteaRepoResult | null = null;
|
||||
if (this.gitea.enabled) {
|
||||
giteaRepo = await this.createGiteaRepo(dto.slug, {
|
||||
name: dto.name,
|
||||
description,
|
||||
visibility,
|
||||
branch,
|
||||
});
|
||||
}
|
||||
|
||||
return this.toResponse(await this.getEntityOrThrow(saved.slugName));
|
||||
// 2) Relay 메타데이터 저장 (Gitea 연동 정보 포함)
|
||||
try {
|
||||
const repo = this.repoRepo.create({
|
||||
slugName: dto.slug,
|
||||
owner,
|
||||
name: dto.name,
|
||||
description,
|
||||
visibility,
|
||||
branch,
|
||||
giteaRepoId: giteaRepo?.id ?? null,
|
||||
giteaFullName: giteaRepo?.fullName ?? null,
|
||||
cloneUrl: giteaRepo?.cloneUrl ?? null,
|
||||
htmlUrl: giteaRepo?.htmlUrl ?? null,
|
||||
});
|
||||
const saved = await this.repoRepo.save(repo);
|
||||
|
||||
const ownerMember = this.memberRepo.create({
|
||||
repo: saved,
|
||||
user,
|
||||
roleType: 'admin',
|
||||
isOwner: true,
|
||||
subRole: '리드',
|
||||
});
|
||||
await this.memberRepo.save(ownerMember);
|
||||
|
||||
return this.toResponse(await this.getEntityOrThrow(saved.slugName));
|
||||
} catch (err) {
|
||||
// Relay 저장 실패 시 방금 만든 Gitea 저장소를 정리(고아 방지, 베스트 에포트)
|
||||
if (giteaRepo) {
|
||||
await this.gitea.deleteRepo(dto.slug).catch((e: unknown) => {
|
||||
this.logger.error(
|
||||
`Gitea 고아 저장소 정리 실패: ${dto.slug}`,
|
||||
e instanceof Error ? e.stack : String(e),
|
||||
);
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 저장소 수정 — admin 권한 필요
|
||||
@@ -121,6 +170,10 @@ export class RepoService {
|
||||
const repo = await this.getEntityOrThrow(slugName);
|
||||
await this.assertAdmin(repo.id, userId);
|
||||
|
||||
// 표시제목/설명은 Gitea description 으로, 공개범위는 private 플래그로 동기화
|
||||
const descChanged = dto.name !== undefined || dto.description !== undefined;
|
||||
const visChanged = dto.visibility !== undefined;
|
||||
|
||||
if (dto.name !== undefined) repo.name = dto.name;
|
||||
if (dto.description !== undefined) repo.description = dto.description;
|
||||
if (dto.visibility !== undefined) repo.visibility = dto.visibility;
|
||||
@@ -128,6 +181,28 @@ export class RepoService {
|
||||
repo.branch = dto.branch.trim();
|
||||
|
||||
await this.repoRepo.save(repo);
|
||||
|
||||
// Gitea 메타데이터 동기화 (베스트 에포트 — 실패해도 Relay 수정은 유지)
|
||||
if (
|
||||
this.gitea.enabled &&
|
||||
repo.giteaRepoId !== null &&
|
||||
(descChanged || visChanged)
|
||||
) {
|
||||
await this.gitea
|
||||
.updateRepo(repo.slugName, {
|
||||
description: descChanged
|
||||
? this.buildGiteaDescription(repo.name, repo.description)
|
||||
: undefined,
|
||||
private: visChanged ? repo.visibility === 'private' : undefined,
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
this.logger.error(
|
||||
`Gitea 저장소 수정 동기화 실패: ${repo.slugName}`,
|
||||
e instanceof Error ? e.stack : String(e),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return this.toResponse(await this.getEntityOrThrow(slugName));
|
||||
}
|
||||
|
||||
@@ -141,10 +216,66 @@ export class RepoService {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.repoRepo.remove(repo);
|
||||
|
||||
// Gitea 저장소도 삭제 (베스트 에포트 — 실패 시 로그만 남기고 Relay 삭제는 유지)
|
||||
if (this.gitea.enabled && repo.giteaRepoId !== null) {
|
||||
await this.gitea.deleteRepo(repo.slugName).catch((e: unknown) => {
|
||||
this.logger.error(
|
||||
`Gitea 저장소 삭제 동기화 실패: ${repo.slugName}`,
|
||||
e instanceof Error ? e.stack : String(e),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- 내부 헬퍼 ---
|
||||
|
||||
// Gitea 조직에 저장소 생성 — 실패 시 사용자 노출 가능한 BusinessException 으로 변환
|
||||
private async createGiteaRepo(
|
||||
slug: string,
|
||||
meta: {
|
||||
name: string;
|
||||
description: string | null;
|
||||
visibility: RepoVisibility;
|
||||
branch: string;
|
||||
},
|
||||
): Promise<GiteaRepoResult> {
|
||||
try {
|
||||
return await this.gitea.createRepo({
|
||||
name: slug,
|
||||
description: this.buildGiteaDescription(meta.name, meta.description),
|
||||
private: meta.visibility === 'private',
|
||||
defaultBranch: meta.branch,
|
||||
});
|
||||
} catch (err) {
|
||||
// Gitea 측 이름 충돌 → Relay 와 동일한 중복 문구로 안내
|
||||
if (err instanceof GiteaApiError && err.status === HTTP_CONFLICT) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'이미 사용 중인 저장소 이름입니다.',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
this.logger.error(
|
||||
`Gitea 저장소 생성 실패: ${slug}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'Gitea 저장소 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea 저장소 설명 문자열 구성 — Gitea 에 별도 표시명 필드가 없어 '표시제목 — 설명' 으로 합친다
|
||||
private buildGiteaDescription(
|
||||
name: string,
|
||||
description: string | null,
|
||||
): string {
|
||||
return description ? `${name} — ${description}` : name;
|
||||
}
|
||||
|
||||
// slugName 으로 저장소(+멤버) 로딩, 없으면 404
|
||||
private async getEntityOrThrow(slugName: string): Promise<Repo> {
|
||||
const repo = await this.repoRepo.findOne({
|
||||
@@ -185,6 +316,8 @@ export class RepoService {
|
||||
// TODO(4단계): 업무 모듈 도입 시 실제 완료/전체 수 집계
|
||||
doneCount: 0,
|
||||
totalCount: 0,
|
||||
cloneUrl: repo.cloneUrl,
|
||||
htmlUrl: repo.htmlUrl,
|
||||
updatedAt: repo.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user