diff --git a/backend/.env.development.example b/backend/.env.development.example index 14816f2..00169d8 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -12,6 +12,16 @@ JWT_REFRESH_SECRET=change-me-refresh-secret JWT_ACCESS_TTL=15m JWT_REFRESH_TTL=14d +# --- Gitea 연동 (외부 Gitea 인스턴스) --- +# 세 값이 모두 채워져야 연동 활성화. 비우면 저장소는 Gitea 동기화 없이 동작한다. +# 저장소 생성/수정/삭제 시 GITEA_ORG 조직 아래 repo 가 자동 동기화된다. +# GITEA_BASE_URL: 끝 슬래시 없이 (/api/v1 는 코드가 붙임) +GITEA_BASE_URL=https://gitea.example.com +# GITEA_TOKEN: 조직 권한을 가진 액세스 토큰 (Gitea Settings → Applications → Generate Token) +GITEA_TOKEN=change-me-gitea-token +# GITEA_ORG: 저장소가 생성될 조직 이름 (Relay 의 owner 로도 사용) +GITEA_ORG=marketing-team + # --- 단독 실행(non-Docker) 시 주석 해제 --- # NODE_ENV=development # DB_HOST=localhost diff --git a/backend/.env.production.example b/backend/.env.production.example index 14816f2..00169d8 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -12,6 +12,16 @@ JWT_REFRESH_SECRET=change-me-refresh-secret JWT_ACCESS_TTL=15m JWT_REFRESH_TTL=14d +# --- Gitea 연동 (외부 Gitea 인스턴스) --- +# 세 값이 모두 채워져야 연동 활성화. 비우면 저장소는 Gitea 동기화 없이 동작한다. +# 저장소 생성/수정/삭제 시 GITEA_ORG 조직 아래 repo 가 자동 동기화된다. +# GITEA_BASE_URL: 끝 슬래시 없이 (/api/v1 는 코드가 붙임) +GITEA_BASE_URL=https://gitea.example.com +# GITEA_TOKEN: 조직 권한을 가진 액세스 토큰 (Gitea Settings → Applications → Generate Token) +GITEA_TOKEN=change-me-gitea-token +# GITEA_ORG: 저장소가 생성될 조직 이름 (Relay 의 owner 로도 사용) +GITEA_ORG=marketing-team + # --- 단독 실행(non-Docker) 시 주석 해제 --- # NODE_ENV=development # DB_HOST=localhost diff --git a/backend/src/modules/gitea/gitea.module.ts b/backend/src/modules/gitea/gitea.module.ts new file mode 100644 index 0000000..eb6c90a --- /dev/null +++ b/backend/src/modules/gitea/gitea.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { GiteaService } from './gitea.service'; + +// Gitea 연동 모듈 — 외부 Gitea 인스턴스의 조직 저장소를 생성/수정/삭제하는 클라이언트 제공 +@Module({ + providers: [GiteaService], + exports: [GiteaService], +}) +export class GiteaModule {} diff --git a/backend/src/modules/gitea/gitea.service.spec.ts b/backend/src/modules/gitea/gitea.service.spec.ts new file mode 100644 index 0000000..2da0404 --- /dev/null +++ b/backend/src/modules/gitea/gitea.service.spec.ts @@ -0,0 +1,121 @@ +import { ConfigService } from '@nestjs/config'; +import { GiteaApiError, GiteaService } from './gitea.service'; + +// ConfigService 간이 스텁 — 주어진 맵에서만 값을 반환 +function configStub(values: Record): ConfigService { + return { + get: (key: string): string | undefined => values[key], + } as unknown as ConfigService; +} + +// fetch 응답 간이 모킹 +function mockResponse(status: number, body: unknown): Promise { + 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'); + }); + }); +}); diff --git a/backend/src/modules/gitea/gitea.service.ts b/backend/src/modules/gitea/gitea.service.ts new file mode 100644 index 0000000..4e17442 --- /dev/null +++ b/backend/src/modules/gitea/gitea.service.ts @@ -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('GITEA_BASE_URL') ?? '').replace( + /\/+$/, + '', + ); + this.token = config.get('GITEA_TOKEN') ?? ''; + this.org = config.get('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 { + const data = await this.request( + '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 { + await this.request( + 'PATCH', + `/repos/${encodeURIComponent(this.org)}/${encodeURIComponent(name)}`, + { + description: patch.description, + private: patch.private, + }, + ); + } + + // 저장소 삭제 + async deleteRepo(name: string): Promise { + await this.request( + 'DELETE', + `/repos/${encodeURIComponent(this.org)}/${encodeURIComponent(name)}`, + ); + } + + // --- 내부 헬퍼 --- + + // Gitea API 공통 요청 — 토큰 헤더 부착, 실패 시 GiteaApiError + private async request( + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + path: string, + body?: Record, + ): Promise { + 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, + }; + } +} diff --git a/backend/src/modules/repo/entities/repo.entity.ts b/backend/src/modules/repo/entities/repo.entity.ts index c593a3d..2203a6f 100644 --- a/backend/src/modules/repo/entities/repo.entity.ts +++ b/backend/src/modules/repo/entities/repo.entity.ts @@ -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[]; diff --git a/backend/src/modules/repo/repo.module.ts b/backend/src/modules/repo/repo.module.ts index d784187..c008cf0 100644 --- a/backend/src/modules/repo/repo.module.ts +++ b/backend/src/modules/repo/repo.module.ts @@ -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], diff --git a/backend/src/modules/repo/repo.service.ts b/backend/src/modules/repo/repo.service.ts index 76c2dde..6c1b96e 100644 --- a/backend/src/modules/repo/repo.service.ts +++ b/backend/src/modules/repo/repo.service.ts @@ -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, @InjectRepository(RepoMember) private readonly memberRepo: Repository, 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 { 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 { + 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 { 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(), }; } diff --git a/docs/api-contract.md b/docs/api-contract.md index a5a335f..ac08dcf 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -85,15 +85,15 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프 | PATCH | `/repos/:repoId` | `UpdateRepoDto` | `Repo` | | DELETE | `/repos/:repoId` | — | `null` | -`CreateRepoDto`: `{ name, desc, visibility('private'|'public'), branch? }` +`CreateRepoDto`: `{ slug, name, visibility('private'|'public'), description?, branch? }` `Repo` 응답(원시값 중심): ```jsonc { "id": "q3-launch-campaign", // slug 마지막 segment = 라우팅 id "name": "3분기 신제품 런칭 캠페인", - "owner": "marketing-team", - "slug": "marketing-team/q3-launch-campaign", + "owner": "marketing-team", // Gitea 연동 시 Gitea 조직명 + "slug": "marketing-team/q3-launch-campaign", // = Gitea full_name "desc": "...", "visibility": "private", "branch": "main", @@ -101,12 +101,30 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프 "memberCount": 5, "doneCount": 8, "totalCount": 12, + "cloneUrl": "https://gitea.example.com/marketing-team/q3-launch-campaign.git", // 미연동 시 null + "htmlUrl": "https://gitea.example.com/marketing-team/q3-launch-campaign", // 미연동 시 null "updatedAt": "2026-06-16T11:00:00Z" } ``` > `moreCount`, `progressPct/Level/Label`, `updatedAgo` 는 프론트 계산. > 매핑 페이지: RepoListPage, RepoCreatePage, RepoDetailPage(헤더), RepoSettingsPage. +#### Gitea 연동 (외부 인스턴스) + +저장소 생명주기를 외부 Gitea 조직에 동기화한다. `GITEA_BASE_URL`/`GITEA_TOKEN`/`GITEA_ORG` +(backend `.env`) 가 모두 설정되면 활성화되고, 비어 있으면 Gitea 호출 없이 기존대로 동작한다. + +| Relay 동작 | Gitea API | 매핑 | +|---|---|---| +| `POST /repos` | `POST /api/v1/orgs/{org}/repos` (auto_init) | `slug`→repo name, `visibility`→private, `branch`→default_branch, `name`+`desc`→description | +| `PATCH /repos/:id` | `PATCH /api/v1/repos/{org}/{slug}` | `name`/`desc` 변경→description, `visibility` 변경→private (slug 불변) | +| `DELETE /repos/:id` | `DELETE /api/v1/repos/{org}/{slug}` | — | + +- 인증: 조직 권한 토큰 단일 사용(`Authorization: token `). 사용자별 Gitea 계정 매핑은 추후. +- 생성은 Gitea 우선 → 성공 시 Relay 저장(연동 정보 `giteaRepoId`/`cloneUrl`/`htmlUrl` 보관). Relay 저장 실패 시 Gitea repo 롤백 삭제. +- Gitea 이름 충돌(409) → `BIZ_001` "이미 사용 중인 저장소 이름입니다.", 그 외 실패 → `BIZ_001`(502). +- 수정/삭제의 Gitea 동기화는 **베스트 에포트**(실패해도 Relay 상태는 반영, 에러 로그만 남김). + --- ## 3. 멤버 (`/api/repos/:repoId/members`) — **3단계** diff --git a/docs/integration-progress.md b/docs/integration-progress.md index 8887ebf..8974222 100644 --- a/docs/integration-progress.md +++ b/docs/integration-progress.md @@ -2,7 +2,7 @@ > 프론트 UI(목업)를 NestJS 백엔드에 단계적으로 연동하는 작업의 진행 기록. > 계약/스키마 상세는 [`api-contract.md`](./api-contract.md) 참조. -> 최종 갱신: 2026-06-16 · 현재 위치: **2단계(저장소) 완료, 3단계(멤버) 대기** +> 최종 갱신: 2026-06-16 · 현재 위치: **2단계(저장소) 완료 + Gitea 연동, 3단계(멤버) 대기** --- @@ -63,6 +63,20 @@ - `types/repo.ts`, `composables/useRepo.ts`, `stores/repo.store.ts`(API→기존 mock `Repo`/`User` 뷰로 매핑: 아바타 3명+`moreCount`, 진행률, 상대시간). - 페이지 연동: **RepoListPage**(목록·로딩/빈상태), **RepoCreatePage**(생성→상세), **RepoDetailPage**(헤더·진행률만), **RepoSettingsPage**(저장·삭제·이름변경 안내). +### 2.5단계 — Gitea 조직 연동 ✅ + +**백엔드** `backend/src/modules/gitea/` +- `GiteaService`: 외부 Gitea(`/api/v1`) 얇은 클라이언트(Node 내장 `fetch`, 새 의존성 없음). `createRepo`/`updateRepo`/`deleteRepo` + `enabled` 게이트. `GiteaApiError`(상태코드 보존). +- 환경변수 `GITEA_BASE_URL`/`GITEA_TOKEN`/`GITEA_ORG` 셋 다 있어야 활성화. 비어 있으면 저장소 CRUD 가 Gitea 호출 없이 기존대로 동작(로컬/미연동 대비). +- `Repo` 엔티티에 연동 컬럼 추가: `giteaRepoId`, `giteaFullName`, `cloneUrl`, `htmlUrl`(전부 nullable). `RepoResponse` 에 `cloneUrl`/`htmlUrl` 노출. +- `RepoService` 와이어링: + - **생성**: Gitea 조직에 repo 생성(`auto_init`) → 성공 시 Relay 저장(연동정보 보관). 소유자=`GITEA_ORG`. 이름 충돌(409)→`BIZ_001`. Relay 저장 실패 시 Gitea repo 롤백 삭제. + - **수정**: 표시제목/설명→Gitea description, 공개범위→private 동기화(slug 불변). 베스트 에포트. + - **삭제**: Gitea repo 삭제 동기화. 베스트 에포트(실패해도 Relay 삭제 유지, 에러 로그). +- 인프라 변경 없음(외부 Gitea). 토큰은 JWT 처럼 `backend/.env.{env}` 의 백엔드 고유 시크릿. 커밋용 `backend/.env.{env}.example` 에 키 문서화. + +> **TODO(프론트)**: RepoSettingsPage/RepoDetailPage 에 `cloneUrl`/`htmlUrl` 표시(클론 주소 복사, Gitea 열기 버튼). 백엔드 응답에는 이미 포함. + --- ## 3. 남은 단계 @@ -107,8 +121,10 @@ - **엔티티 순환참조**: 서로 참조하는 관계(예: `Repo`↔`RepoMember`)의 **단일 클래스 관계 속성에는 TypeORM `Relation<>` 래퍼**를 사용한다. 안 그러면 SWC 환경에서 `Cannot access 'X' before initialization` 크래시. → 4·5단계(Task↔Repo, Comment↔Task 등)에도 동일 적용. - **bcrypt → bcryptjs**: Docker 베이스가 `node:20-alpine`(빌드 도구 없음)이라 네이티브 모듈 `bcrypt` 대신 순수 JS `bcryptjs` 사용. - **dev 도커 익명 볼륨**: `/app/node_modules` 가 익명 볼륨이라 **의존성 변경 시 `up -d --build -V`(익명 볼륨 갱신)** 필요. `--build` 만으로는 기존 node_modules 볼륨이 재사용되어 새 패키지가 반영되지 않음. -- **owner 고정**: 조직 모델이 없어 `owner='marketing-team'` 상수. 조직 도입 시 교체. +- **owner 고정**: 조직 모델이 없어 `owner='marketing-team'` 상수. **Gitea 연동 시 owner=`GITEA_ORG`** 로 대체(미연동 시 상수 폴백). 조직 도입 시 교체. - **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 없이도 기존 흐름 유지. --- @@ -124,5 +140,14 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d - 백엔드: `npm run build` / `npm run lint` (현재 0 error) - 프론트: `npm run type-check` / `npm run lint` / `npm run build-only` (현재 0 error) -- dev `synchronize=true` 가 `users`·`repos`·`repo_members` 테이블 자동 생성. DB가 비어 있으므로 **회원가입 후 로그인**. +- dev `synchronize=true` 가 `users`·`repos`·`repo_members` 테이블 자동 생성(Gitea 연동 컬럼 포함). DB가 비어 있으므로 **회원가입 후 로그인**. - 포트: 프론트 5273, 백엔드 3100(`/api`), Swagger `http://localhost:3100/api-docs`. + +### Gitea 연동 설정/검증 + +1. `backend/.env.{env}` 에 `GITEA_BASE_URL`/`GITEA_TOKEN`/`GITEA_ORG` 채우기(예시: `backend/.env.{env}.example`). + - 토큰: Gitea → Settings → Applications → Generate Token(조직 repo 생성 권한 필요). + - docker-compose 는 `backend/.env.{env}` 를 `env_file` 로 자동 주입하므로 compose 수정 불필요. +2. 부팅 로그에서 `Gitea 연동 활성화 — (org: )` 확인(미설정 시 비활성 경고). +3. Relay 에서 저장소 생성 → Gitea `` 조직에 동일 slug repo 가 생기는지 확인. 응답 `cloneUrl`/`htmlUrl` 채워짐. +4. 수정(설명/공개범위)·삭제 시 Gitea 측 반영 확인.