From 7f432a001320bc68b0717eab7a6e1b86b0b8e30f Mon Sep 17 00:00:00 2001 From: TtiPo Date: Wed, 17 Jun 2026 00:20:21 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20=EC=84=A4=EB=AA=85=20=ED=95=84=EC=88=98?= =?UTF-8?q?=ED=99=94=20+=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EB=B3=B5?= =?UTF-8?q?=EC=A0=9C=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로젝트 설명을 생성 필수 항목으로 변경(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) --- backend/.env.development.example | 3 ++ backend/.env.production.example | 3 ++ backend/src/modules/gitea/gitea.service.ts | 39 ++++++++++++++++ .../src/modules/repo/dto/create-repo.dto.ts | 21 +++++++-- backend/src/modules/repo/repo.controller.ts | 13 ++++-- backend/src/modules/repo/repo.service.ts | 46 +++++++++++++++---- docs/api-contract.md | 11 ++++- docs/integration-progress.md | 1 + frontend/src/composables/useRepo.ts | 16 ++++--- frontend/src/pages/relay/RepoCreatePage.vue | 39 ++++++++++------ frontend/src/pages/relay/RepoListPage.vue | 2 +- frontend/src/stores/repo.store.ts | 21 +++++---- frontend/src/types/repo.ts | 9 +++- 13 files changed, 173 insertions(+), 51 deletions(-) diff --git a/backend/.env.development.example b/backend/.env.development.example index 00169d8..619b3ff 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -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 diff --git a/backend/.env.production.example b/backend/.env.production.example index 00169d8..619b3ff 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -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 diff --git a/backend/src/modules/gitea/gitea.service.ts b/backend/src/modules/gitea/gitea.service.ts index 4e17442..62944cb 100644 --- a/backend/src/modules/gitea/gitea.service.ts +++ b/backend/src/modules/gitea/gitea.service.ts @@ -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('GITEA_BASE_URL') ?? '').replace( @@ -48,6 +51,9 @@ export class GiteaService { ); this.token = config.get('GITEA_TOKEN') ?? ''; this.org = config.get('GITEA_ORG') ?? ''; + this.templateOwner = config.get('GITEA_TEMPLATE_OWNER') ?? 'TtiPo'; + this.templateRepo = + config.get('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 { + const data = await this.request( + '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, diff --git a/backend/src/modules/repo/dto/create-repo.dto.ts b/backend/src/modules/repo/dto/create-repo.dto.ts index b58f2fa..c6b679d 100644 --- a/backend/src/modules/repo/dto/create-repo.dto.ts +++ b/backend/src/modules/repo/dto/create-repo.dto.ts @@ -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; } diff --git a/backend/src/modules/repo/repo.controller.ts b/backend/src/modules/repo/repo.controller.ts index 9830fed..4e27929 100644 --- a/backend/src/modules/repo/repo.controller.ts +++ b/backend/src/modules/repo/repo.controller.ts @@ -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') diff --git a/backend/src/modules/repo/repo.service.ts b/backend/src/modules/repo/repo.service.ts index b2b2414..27c7091 100644 --- a/backend/src/modules/repo/repo.service.ts +++ b/backend/src/modules/repo/repo.service.ts @@ -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 { + 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) { diff --git a/docs/api-contract.md b/docs/api-contract.md index ac08dcf..bd445f9 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -80,12 +80,17 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프 | 메서드 | 경로 | 요청 | 응답 | |---|---|---|---| | GET | `/repos` | `?page&size` | `Repo[]`(요약) — 내가 멤버인 저장소 | +| GET | `/repos/meta` | — | `{ owner, template }` — 생성 폼 메타(`:repoId` 보다 먼저 매칭) | | POST | `/repos` | `CreateRepoDto` | `Repo` | | GET | `/repos/:repoId` | — | `Repo`(상세) | | PATCH | `/repos/:repoId` | `UpdateRepoDto` | `Repo` | | DELETE | `/repos/:repoId` | — | `null` | -`CreateRepoDto`: `{ slug, name, visibility('private'|'public'), description?, branch? }` +`GET /repos/meta` 응답: `{ owner: string, template: { available: boolean, slug: string } }` +— 생성 전 소유자(조직) 고정 표시 + 템플릿 옵션 노출용. + +`CreateRepoDto`: `{ slug, name, visibility('private'|'public'), description, branch?, useTemplate? }` +(**description 필수**, `useTemplate=true` 면 템플릿 저장소를 복제해 생성) `Repo` 응답(원시값 중심): ```jsonc @@ -116,10 +121,12 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프 | Relay 동작 | Gitea API | 매핑 | |---|---|---| -| `POST /repos` | `POST /api/v1/orgs/{org}/repos` (auto_init) | `slug`→repo name, `visibility`→private, `branch`→default_branch, `name`+`desc`→description | +| `POST /repos` (빈 저장소) | `POST /api/v1/orgs/{org}/repos` (auto_init) | `slug`→repo name, `visibility`→private, `branch`→default_branch, `name`+`desc`→description | +| `POST /repos` (`useTemplate`) | `POST /api/v1/repos/{tplOwner}/{tplRepo}/generate` (git_content) | `owner`→org, `slug`→name, `name`+`desc`→description, `visibility`→private. 기본 브랜치는 템플릿을 따름 | | `PATCH /repos/:id` | `PATCH /api/v1/repos/{org}/{slug}` | `name`/`desc` 변경→description, `visibility` 변경→private (slug 불변) | | `DELETE /repos/:id` | `DELETE /api/v1/repos/{org}/{slug}` | — | +- 템플릿 출처: `GITEA_TEMPLATE_OWNER`/`GITEA_TEMPLATE_REPO`(기본 `TtiPo`/`project-base`). 해당 저장소는 Gitea 에서 *template* 로 설정돼 있어야 한다. - 인증: 조직 권한 토큰 단일 사용(`Authorization: token `). 사용자별 Gitea 계정 매핑은 추후. - 생성은 Gitea 우선 → 성공 시 Relay 저장(연동 정보 `giteaRepoId`/`cloneUrl`/`htmlUrl` 보관). Relay 저장 실패 시 Gitea repo 롤백 삭제. - Gitea 이름 충돌(409) → `BIZ_001` "이미 사용 중인 저장소 이름입니다.", 그 외 실패 → `BIZ_001`(502). diff --git a/docs/integration-progress.md b/docs/integration-progress.md index a7579cc..10a91b4 100644 --- a/docs/integration-progress.md +++ b/docs/integration-progress.md @@ -133,6 +133,7 @@ - **ID 전략**: User/Repo PK=UUID, Repo 라우팅 식별자=`slugName`, Task=저장소 내 순번(#9, 4단계). - **Gitea 연동 방식**: HTTP 클라이언트는 새 의존성(@nestjs/axios) 없이 Node 20 내장 `fetch` 사용(베이스 이미지 alpine 호환). 인증은 조직 토큰 1개(사용자별 Gitea 계정 매핑은 8단계 후보). `slug`↔Gitea repo name 1:1, 한글 표시제목은 Gitea description 에 합쳐 저장(Gitea 에 표시명 필드 없음). - **연동 비활성 폴백**: `GITEA_*` 미설정 시 `GiteaService.enabled=false` → 저장소 CRUD 가 Gitea 없이 동작. 로컬 개발/테스트에서 Gitea 없이도 기존 흐름 유지. +- **저장소 생성 폼 보강**: ① **프로젝트 설명 필수화**(`CreateRepoDto.description` required, 프론트 검증 추가). ② **템플릿 복제** — `useTemplate=true` 면 Gitea generate API 로 `GITEA_TEMPLATE_OWNER/REPO`(기본 `TtiPo/project-base`, *template* 설정 필요) 저장소를 복제 생성(브랜치는 템플릿을 따름). 프론트는 `GET /repos/meta`(owner+template) 로 owner 고정표시·템플릿 옵션을 받아 하드코딩 제거. --- diff --git a/frontend/src/composables/useRepo.ts b/frontend/src/composables/useRepo.ts index 45fb59f..c350fea 100644 --- a/frontend/src/composables/useRepo.ts +++ b/frontend/src/composables/useRepo.ts @@ -1,5 +1,10 @@ import { useApi } from './useApi' -import type { ApiRepo, CreateRepoPayload, UpdateRepoPayload } from '@/types/repo' +import type { + ApiRepo, + CreateRepoPayload, + RepoCreateMeta, + UpdateRepoPayload, +} from '@/types/repo' // 저장소 도메인 API Composable — 인터셉터가 success/data 를 언래핑 export function useRepo() { @@ -9,10 +14,9 @@ export function useRepo() { return (await api.get('/repos')) as unknown as ApiRepo[] } - // 현재 조직(소유자) — 생성 폼의 "소유자 고정" 표시용 - async function owner(): Promise { - const res = (await api.get('/repos/owner')) as unknown as { owner: string } - return res.owner + // 저장소 생성 폼 메타(소유자/템플릿) — 생성 전 미리 조회 + async function meta(): Promise { + return (await api.get('/repos/meta')) as unknown as RepoCreateMeta } async function get(id: string): Promise { @@ -31,5 +35,5 @@ export function useRepo() { await api.delete(`/repos/${id}`) } - return { list, owner, get, create, update, remove } + return { list, meta, get, create, update, remove } } diff --git a/frontend/src/pages/relay/RepoCreatePage.vue b/frontend/src/pages/relay/RepoCreatePage.vue index 133e706..c8f2233 100644 --- a/frontend/src/pages/relay/RepoCreatePage.vue +++ b/frontend/src/pages/relay/RepoCreatePage.vue @@ -11,11 +11,11 @@ import type { Visibility } from '@/mock/relay.mock' const router = useRouter() const repoStore = useRepoStore() -// 현재 조직(소유자) — 백엔드 설정값(GITEA_ORG)을 받아 고정 표시 -const { owner } = storeToRefs(repoStore) +// 생성 폼 메타 — 소유자(GITEA_ORG) 고정 표시 + 템플릿 가용 정보 +const { owner, templateAvailable, templateSlug } = storeToRefs(repoStore) const ownerInitial = computed(() => initialOf(owner.value || '?').toUpperCase()) onMounted(() => { - void repoStore.fetchOwner() + void repoStore.fetchCreateMeta() }) // 폼 상태 @@ -23,12 +23,11 @@ const repoName = ref('') const displayTitle = ref('') const visibility = ref('private') const description = ref('') -const template = ref('없음 (빈 저장소)') +// 템플릿 사용 여부 — 사용 시 설정된 템플릿 저장소(project-base)를 복제해 생성 +const useTemplate = ref(false) const branch = ref('main') const submitting = ref(false) -const templates = ['없음 (빈 저장소)', '기본 업무 템플릿', '디자인 프로젝트 템플릿', '개발 프로젝트 템플릿'] - // 생성 — 성공 시 새 저장소 상세로 이동 async function createRepo() { if (submitting.value) return @@ -46,6 +45,10 @@ async function createRepo() { window.alert('표시 제목을 입력해 주세요.') return } + if (!description.value.trim()) { + window.alert('프로젝트 설명을 입력해 주세요.') + return + } submitting.value = true try { @@ -53,8 +56,9 @@ async function createRepo() { slug: repoName.value.trim(), name: displayTitle.value.trim(), visibility: visibility.value, - description: description.value.trim() || undefined, + description: description.value.trim(), branch: branch.value.trim() || undefined, + useTemplate: useTemplate.value, }) await router.push(`/repos/${repo.id}`) } catch { @@ -251,15 +255,16 @@ async function createRepo() {
- 프로젝트 설명 + * 프로젝트 설명
- 저장소 목록과 상단에 표시됩니다. (선택) + 저장소 목록과 상단에 표시됩니다. 프로젝트의 목적과 범위를 간단히 적어주세요.