feat: 저장소 생성 시 설명 필수화 + 템플릿 복제 생성

- 프로젝트 설명을 생성 필수 항목으로 변경(CreateRepoDto.description required + 프론트 검증)
- 템플릿 복제: useTemplate=true 면 Gitea generate API 로 GITEA_TEMPLATE_OWNER/REPO
  (기본 TtiPo/project-base) 저장소를 복제 생성(브랜치는 템플릿을 따름)
- GET /repos/owner → /repos/meta 로 확장({ owner, template }) — 생성폼 메타 일괄 제공
- 프론트 생성폼: 가짜 템플릿 목록 제거 → 실제 템플릿 옵션, 설명 필수 UI
- env.example/ api-contract/ 진행현황 문서 갱신

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 00:20:21 +09:00
parent 6652e79768
commit 7f432a0013
13 changed files with 173 additions and 51 deletions
+3
View File
@@ -21,6 +21,9 @@ GITEA_BASE_URL=https://gitea.example.com
GITEA_TOKEN=change-me-gitea-token
# GITEA_ORG: 저장소가 생성될 조직 이름 (Relay 의 owner 로도 사용)
GITEA_ORG=marketing-team
# 템플릿 저장소(생성 시 복제) — 미설정 시 기본 TtiPo/project-base 사용. Gitea 에서 template 설정 필요
GITEA_TEMPLATE_OWNER=TtiPo
GITEA_TEMPLATE_REPO=project-base
# --- 단독 실행(non-Docker) 시 주석 해제 ---
# NODE_ENV=development
+3
View File
@@ -21,6 +21,9 @@ GITEA_BASE_URL=https://gitea.example.com
GITEA_TOKEN=change-me-gitea-token
# GITEA_ORG: 저장소가 생성될 조직 이름 (Relay 의 owner 로도 사용)
GITEA_ORG=marketing-team
# 템플릿 저장소(생성 시 복제) — 미설정 시 기본 TtiPo/project-base 사용. Gitea 에서 template 설정 필요
GITEA_TEMPLATE_OWNER=TtiPo
GITEA_TEMPLATE_REPO=project-base
# --- 단독 실행(non-Docker) 시 주석 해제 ---
# NODE_ENV=development
@@ -40,6 +40,9 @@ export class GiteaService {
private readonly baseUrl: string; // 끝 슬래시 제거된 형태
private readonly token: string;
readonly org: string;
// 템플릿 저장소(이 저장소를 복제해 새 repo 생성). 기본값: TtiPo/project-base
readonly templateOwner: string;
readonly templateRepo: string;
constructor(config: ConfigService) {
this.baseUrl = (config.get<string>('GITEA_BASE_URL') ?? '').replace(
@@ -48,6 +51,9 @@ export class GiteaService {
);
this.token = config.get<string>('GITEA_TOKEN') ?? '';
this.org = config.get<string>('GITEA_ORG') ?? '';
this.templateOwner = config.get<string>('GITEA_TEMPLATE_OWNER') ?? 'TtiPo';
this.templateRepo =
config.get<string>('GITEA_TEMPLATE_REPO') ?? 'project-base';
if (this.enabled) {
this.logger.log(`Gitea 연동 활성화 — ${this.baseUrl} (org: ${this.org})`);
@@ -63,6 +69,16 @@ export class GiteaService {
return Boolean(this.baseUrl && this.token && this.org);
}
// 템플릿 식별자 (owner/repo) — 프론트 표시·로깅용
get templateSlug(): string {
return `${this.templateOwner}/${this.templateRepo}`;
}
// 템플릿 복제 사용 가능 여부 — 연동 활성 + 템플릿 owner/repo 설정
get templateEnabled(): boolean {
return this.enabled && Boolean(this.templateOwner && this.templateRepo);
}
// 조직 아래 저장소 생성 (초기 커밋/기본 브랜치 포함)
async createRepo(input: {
name: string; // = Relay slugName
@@ -84,6 +100,29 @@ export class GiteaService {
return this.toResult(data);
}
// 템플릿 저장소를 복제해 조직 아래 새 저장소 생성 (git 콘텐츠 포함).
// 생성된 저장소의 기본 브랜치는 템플릿을 따른다.
async generateFromTemplate(input: {
name: string; // = Relay slugName
description: string;
private: boolean;
}): Promise<GiteaRepoResult> {
const data = await this.request<GiteaRepoApi>(
'POST',
`/repos/${encodeURIComponent(this.templateOwner)}/${encodeURIComponent(
this.templateRepo,
)}/generate`,
{
owner: this.org, // 대상 조직
name: input.name,
description: input.description,
private: input.private,
git_content: true, // 템플릿의 파일/폴더 구조 복제
},
);
return this.toResult(data);
}
// 저장소 메타데이터(설명/공개범위) 수정 — slug(name) 는 변경하지 않음
async updateRepo(
name: string,
@@ -1,4 +1,5 @@
import {
IsBoolean,
IsIn,
IsNotEmpty,
IsOptional,
@@ -41,13 +42,27 @@ export class CreateRepoDto {
@IsIn(['private', 'public'], { message: '공개 범위를 선택해 주세요.' })
visibility!: RepoVisibility;
@ApiProperty({ description: '프로젝트 설명(선택)', required: false })
@IsOptional()
@ApiProperty({
description: '프로젝트 설명(필수)',
example: '3분기 신제품 런칭을 위한 마케팅 캠페인 저장소',
})
@IsString()
description?: string;
@IsNotEmpty({ message: '프로젝트 설명은 필수 입력 항목입니다.' })
@MaxLength(300)
description!: string;
@ApiProperty({ description: '메인 브랜치', example: 'main', required: false })
@IsOptional()
@IsString()
branch?: string;
@ApiProperty({
description:
'템플릿 사용 여부 — true 면 설정된 템플릿 저장소(GITEA_TEMPLATE)를 복제해 생성',
example: true,
required: false,
})
@IsOptional()
@IsBoolean()
useTemplate?: boolean;
}
+8 -5
View File
@@ -47,12 +47,15 @@ export class RepoController {
return this.repoService.create(dto, user.id);
}
// ':repoId' 보다 먼저 선언해야 'owner' 가 파라미터로 매칭되지 않음
@Get('owner')
@ApiOperation({ summary: '현재 조직(소유자) 조회 — 생성 폼 표시용' })
// ':repoId' 보다 먼저 선언해야 'meta' 가 파라미터로 매칭되지 않음
@Get('meta')
@ApiOperation({ summary: '저장소 생성 폼 메타(소유자/템플릿) 조회' })
@ApiResponse({ status: 200, description: '조회 성공' })
getOwner(): { owner: string } {
return { owner: this.repoService.getOwner() };
getCreateMeta(): {
owner: string;
template: { available: boolean; slug: string };
} {
return this.repoService.getCreateMeta();
}
@Get(':repoId')
+36 -10
View File
@@ -57,10 +57,20 @@ export class RepoService {
private readonly gitea: GiteaService,
) {}
// 현재 저장소 소유자(조직) — Gitea 연동 시 GITEA_ORG, 아니면 기본 상수.
// 프론트 생성 폼의 "소유자 고정" 표시에 사용(저장소 생성 전 미리 노출).
getOwner(): string {
return this.gitea.enabled ? this.gitea.org : DEFAULT_OWNER;
// 저장소 생성 폼 메타데이터 — 소유자(조직) 고정 표시 + 템플릿 가용 정보.
// 저장소 생성 전 프론트가 미리 조회하여 표시한다.
getCreateMeta(): {
owner: string;
template: { available: boolean; slug: string };
} {
return {
// 연동 시 owner = GITEA_ORG, 아니면 기본 상수
owner: this.gitea.enabled ? this.gitea.org : DEFAULT_OWNER,
template: {
available: this.gitea.templateEnabled,
slug: this.gitea.templateSlug,
},
};
}
// 내가 멤버인 저장소 목록
@@ -110,22 +120,27 @@ export class RepoService {
throw new NotFoundException();
}
const description = dto.description ?? null;
const description = dto.description;
const visibility = dto.visibility;
const branch = dto.branch?.trim() || 'main';
const requestedBranch = dto.branch?.trim() || 'main';
// 연동 시 소유자는 Gitea 조직명 — slug `owner/slugName` 이 Gitea full_name 과 일치
const owner = this.gitea.enabled ? this.gitea.org : DEFAULT_OWNER;
// 1) Gitea 조직에 실제 저장소 생성 (연동 활성 시)
// - useTemplate: 템플릿 저장소를 복제해 생성(브랜치는 템플릿을 따름)
// - 아니면 빈 저장소(auto_init, 요청 브랜치)
let giteaRepo: GiteaRepoResult | null = null;
if (this.gitea.enabled) {
giteaRepo = await this.createGiteaRepo(dto.slug, {
name: dto.name,
description,
visibility,
branch,
branch: requestedBranch,
useTemplate: dto.useTemplate ?? false,
});
}
// 템플릿 복제 시 실제 기본 브랜치는 Gitea 응답값을 우선 사용
const branch = giteaRepo?.defaultBranch || requestedBranch;
// 2) Relay 메타데이터 저장 (Gitea 연동 정보 포함)
try {
@@ -236,7 +251,8 @@ export class RepoService {
// --- 내부 헬퍼 ---
// Gitea 조직에 저장소 생성 — 실패 시 사용자 노출 가능한 BusinessException 으로 변환
// Gitea 조직에 저장소 생성 — 실패 시 사용자 노출 가능한 BusinessException 으로 변환.
// useTemplate 이면 템플릿 저장소를 복제(generate), 아니면 빈 저장소(auto_init).
private async createGiteaRepo(
slug: string,
meta: {
@@ -244,13 +260,23 @@ export class RepoService {
description: string | null;
visibility: RepoVisibility;
branch: string;
useTemplate: boolean;
},
): Promise<GiteaRepoResult> {
const description = this.buildGiteaDescription(meta.name, meta.description);
const isPrivate = meta.visibility === 'private';
try {
if (meta.useTemplate) {
return await this.gitea.generateFromTemplate({
name: slug,
description,
private: isPrivate,
});
}
return await this.gitea.createRepo({
name: slug,
description: this.buildGiteaDescription(meta.name, meta.description),
private: meta.visibility === 'private',
description,
private: isPrivate,
defaultBranch: meta.branch,
});
} catch (err) {