From e2fe089321ca5901db6ebd8813806b679f776aa9 Mon Sep 17 00:00:00 2001 From: ttipo Date: Wed, 17 Jun 2026 18:11:16 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Gitea=20=ED=94=84=EB=A1=A0=ED=8A=B8=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99(=ED=81=B4=EB=A1=A0=EB=B0=94=C2=B7=EC=9B=90?= =?UTF-8?q?=EA=B2=A9=EC=97=86=EC=9D=8C=20=EB=B0=B0=EC=A7=80)=C2=B7import-a?= =?UTF-8?q?dmin=20=EC=9D=B8=ED=94=84=EB=9D=BC=20=EB=A7=88=EB=AC=B4?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GiteaRepoBar 컴포넌트(클론 주소 복사 + Gitea 열기)를 RepoSettingsPage 에 적용 - repo.store 뷰에 cloneUrl/htmlUrl/giteaMissing 매핑 - repo-sync: import 저장소 owner-admin 자동 지정(REPO_IMPORT_ADMIN_EMAIL) - env example·docker-compose.dev 에 Gitea/import-admin 키 정비 Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/.env.development.example | 15 +- backend/.env.production.example | 15 +- backend/src/modules/repo/repo-sync.service.ts | 64 +++++- docker-compose.dev.yml | 8 + frontend/.env.development.example | 5 - frontend/.env.production.example | 5 - frontend/src/components/GiteaRepoBar.vue | 191 ++++++++++++++++++ frontend/src/pages/relay/RepoSettingsPage.vue | 10 + frontend/src/stores/repo.store.ts | 3 + 9 files changed, 294 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/GiteaRepoBar.vue diff --git a/backend/.env.development.example b/backend/.env.development.example index cb2d0b6..cc2b3ae 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -1,6 +1,7 @@ # ========================================== -# 백엔드 고유 설정 예시 — 복사해 .env.development 생성 -# 루트 .env 에서 내려주지 않는 값만 작성한다 (JWT 등) +# 백엔드 고유 설정 — 루트 .env 에서 내려주지 않는 값만 작성 (JWT/Gitea 등) +# DB/Redis/CORS/포트 등은 docker-compose 가 루트 .env.development 에서 주입한다. +# .example 을 복사해 .env.development 를 만들고 실제 값으로 채운다. # ========================================== # JWT 시크릿 (access/refresh 분리) — 임의의 강력한 랜덤 문자열로 교체 @@ -13,7 +14,7 @@ JWT_ACCESS_TTL=15m JWT_REFRESH_TTL=14d # --- Gitea 연동 (외부 Gitea 인스턴스) --- -# 세 값이 모두 채워져야 연동 활성화. 비우면 저장소는 Gitea 동기화 없이 동작한다. +# 세 값(BASE_URL/TOKEN/ORG)이 모두 채워져야 연동 활성화. 비우면 Gitea 동기화 없이 동작한다. # 저장소 생성/수정/삭제 시 GITEA_ORG 조직 아래 repo 가 자동 동기화된다. # GITEA_BASE_URL: 끝 슬래시 없이 (/api/v1 는 코드가 붙임) GITEA_BASE_URL=https://gitea.example.com @@ -22,5 +23,9 @@ 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 \ No newline at end of file +GITEA_TEMPLATE_OWNER=ttipo +GITEA_TEMPLATE_REPO=project-base +# import(역방향 동기화로 가져온, 멤버 0명) 저장소의 기본 owner-admin 으로 지정할 가입 사용자 이메일. +# 동기화는 시스템 작업이라 실행 주체가 없으므로 여기서 지정한다. +# 비우거나 미가입 이메일이면 import 저장소는 멤버 0명으로 남고(초대 불가), 가입 후 다음 동기화에서 지정된다. +REPO_IMPORT_ADMIN_EMAIL=admin@example.com diff --git a/backend/.env.production.example b/backend/.env.production.example index cb2d0b6..507b8f5 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -1,6 +1,7 @@ # ========================================== -# 백엔드 고유 설정 예시 — 복사해 .env.development 생성 -# 루트 .env 에서 내려주지 않는 값만 작성한다 (JWT 등) +# 백엔드 고유 설정 — 루트 .env 에서 내려주지 않는 값만 작성 (JWT/Gitea 등) +# DB/Redis/CORS/포트 등은 docker-compose 가 루트 .env.production 에서 주입한다. +# .example 을 복사해 .env.production 를 만들고 실제 값으로 채운다. # ========================================== # JWT 시크릿 (access/refresh 분리) — 임의의 강력한 랜덤 문자열로 교체 @@ -13,7 +14,7 @@ JWT_ACCESS_TTL=15m JWT_REFRESH_TTL=14d # --- Gitea 연동 (외부 Gitea 인스턴스) --- -# 세 값이 모두 채워져야 연동 활성화. 비우면 저장소는 Gitea 동기화 없이 동작한다. +# 세 값(BASE_URL/TOKEN/ORG)이 모두 채워져야 연동 활성화. 비우면 Gitea 동기화 없이 동작한다. # 저장소 생성/수정/삭제 시 GITEA_ORG 조직 아래 repo 가 자동 동기화된다. # GITEA_BASE_URL: 끝 슬래시 없이 (/api/v1 는 코드가 붙임) GITEA_BASE_URL=https://gitea.example.com @@ -22,5 +23,9 @@ 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 \ No newline at end of file +GITEA_TEMPLATE_OWNER=ttipo +GITEA_TEMPLATE_REPO=project-base +# import(역방향 동기화로 가져온, 멤버 0명) 저장소의 기본 owner-admin 으로 지정할 가입 사용자 이메일. +# 동기화는 시스템 작업이라 실행 주체가 없으므로 여기서 지정한다. +# 비우거나 미가입 이메일이면 import 저장소는 멤버 0명으로 남고(초대 불가), 가입 후 다음 동기화에서 지정된다. +REPO_IMPORT_ADMIN_EMAIL=admin@example.com diff --git a/backend/src/modules/repo/repo-sync.service.ts b/backend/src/modules/repo/repo-sync.service.ts index 007804e..34fa3c9 100644 --- a/backend/src/modules/repo/repo-sync.service.ts +++ b/backend/src/modules/repo/repo-sync.service.ts @@ -1,9 +1,13 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Cron, CronExpression } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { UserService } from '../user/user.service'; +import type { User } from '../user/entities/user.entity'; import { GiteaService } from '../gitea/gitea.service'; import { Repo } from './entities/repo.entity'; +import { RepoMember } from './entities/repo-member.entity'; // Gitea description 에서 분리한 표시명/설명 interface ParsedDescription { @@ -21,12 +25,23 @@ export class RepoSyncService implements OnApplicationBootstrap { private readonly logger = new Logger(RepoSyncService.name); // 동기화 중복 실행 방지(부트스트랩과 스케줄이 겹치는 경우) private running = false; + // import(멤버 0명) 저장소에 기본 owner-admin 으로 지정할 사용자 이메일. + // 동기화는 시스템 작업이라 실행 주체가 없으므로 env 로 지정한다. 비어 있으면 멤버 0명으로 둔다. + private readonly importAdminEmail: string; constructor( @InjectRepository(Repo) private readonly repoRepo: Repository, + @InjectRepository(RepoMember) + private readonly memberRepo: Repository, + private readonly userService: UserService, private readonly gitea: GiteaService, - ) {} + config: ConfigService, + ) { + this.importAdminEmail = ( + config.get('REPO_IMPORT_ADMIN_EMAIL') ?? '' + ).trim(); + } // 서버 기동 직후 1회 동기화 — 부팅을 막지 않도록 비차단(백그라운드)으로 실행 onApplicationBootstrap(): void { @@ -111,8 +126,12 @@ export class RepoSyncService implements OnApplicationBootstrap { } } + // import(멤버 0명) 저장소에 기본 owner-admin 지정 — 멤버 할당 데드락 방지. + // (env REPO_IMPORT_ADMIN_EMAIL 미설정/미가입 시 건너뜀) + const adminAssigned = await this.ensureImportAdmin(); + this.logger.log( - `Gitea 동기화 완료 (${trigger}) — 원격 ${remote.length}개 / 신규 ${imported} · 갱신 ${relinked} · 누락표시 ${missing}`, + `Gitea 동기화 완료 (${trigger}) — 원격 ${remote.length}개 / 신규 ${imported} · 갱신 ${relinked} · 누락표시 ${missing} · admin지정 ${adminAssigned}`, ); } catch (e) { this.logger.error( @@ -124,6 +143,47 @@ export class RepoSyncService implements OnApplicationBootstrap { } } + // import(멤버 0명) Gitea 연동 저장소에 기본 owner-admin 을 지정한다. + // env REPO_IMPORT_ADMIN_EMAIL 의 가입 사용자를 owner-admin 으로 등록(신규 import + 기존 멤버 0명 모두 대상). + // 반환: 이번 실행에서 admin 을 새로 붙인 저장소 수. + private async ensureImportAdmin(): Promise { + if (!this.importAdminEmail) return 0; + + const admin = await this.userService.findByEmail(this.importAdminEmail); + if (!admin) { + this.logger.warn( + `REPO_IMPORT_ADMIN_EMAIL(${this.importAdminEmail}) 에 해당하는 가입 사용자가 없어 import 저장소 admin 지정을 건너뜁니다. (가입 후 다음 동기화에서 지정됨)`, + ); + return 0; + } + + // Gitea 연동(import) 저장소 중 멤버가 0명인 것만 대상. + // Relay 생성 저장소는 생성자(owner)가 항상 멤버이므로 제외된다. + const linked = await this.repoRepo.find({ relations: ['members'] }); + let assigned = 0; + for (const repo of linked) { + if (repo.giteaRepoId === null) continue; + if ((repo.members?.length ?? 0) > 0) continue; + await this.assignOwnerAdmin(repo, admin); + assigned++; + this.logger.log( + `import 저장소 owner-admin 지정: ${repo.slugName} ← ${admin.email}`, + ); + } + return assigned; + } + + // 저장소에 사용자를 owner-admin 멤버로 등록(Relay 생성 시 owner 등록과 동일 규약) + private async assignOwnerAdmin(repo: Repo, user: User): Promise { + const member = this.memberRepo.create({ + repo, + user, + roleType: 'admin', + isOwner: true, + }); + await this.memberRepo.save(member); + } + // 기존 저장소에 Gitea 연동정보를 채우고 누락표시를 해제 — 변경이 있었으면 true private backfillLinkage( repo: Repo, diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 5498a27..0b6743b 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,5 +1,7 @@ services: frontend: + # dev에서는 Docker Desktop 시작 시 자동 실행 방지 (수동 up 때만 실행) + restart: "no" build: target: build-stage command: npm run dev -- --host 0.0.0.0 --port 80 @@ -16,6 +18,8 @@ services: - app-network backend: + # dev에서는 Docker Desktop 시작 시 자동 실행 방지 (수동 up 때만 실행) + restart: "no" build: target: builder command: npm run start:dev @@ -33,10 +37,14 @@ services: - app-network db: + # dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 always 를 덮어씀) + restart: "no" ports: - "${DB_PORT}:5432" redis: + # dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 always 를 덮어씀) + restart: "no" ports: - "${REDIS_PORT}:6379" diff --git a/frontend/.env.development.example b/frontend/.env.development.example index de347d8..e69de29 100644 --- a/frontend/.env.development.example +++ b/frontend/.env.development.example @@ -1,5 +0,0 @@ -# ========================================== -# 프론트엔드 고유 설정 예시 — 복사해 .env.development 생성 -# VITE_API_BASE_URL 은 docker-compose 가 루트 .env 의 BACKEND_API_URL 을 -# build arg 로 주입하므로 별도로 작성하지 않는다. -# ========================================== diff --git a/frontend/.env.production.example b/frontend/.env.production.example index 523a653..e69de29 100644 --- a/frontend/.env.production.example +++ b/frontend/.env.production.example @@ -1,5 +0,0 @@ -# ========================================== -# 프론트엔드 고유 설정 예시 — 복사해 .env.production 생성 -# VITE_API_BASE_URL 은 docker-compose 가 루트 .env 의 BACKEND_API_URL 을 -# build arg 로 주입하므로 별도로 작성하지 않는다. -# ========================================== diff --git a/frontend/src/components/GiteaRepoBar.vue b/frontend/src/components/GiteaRepoBar.vue new file mode 100644 index 0000000..cfddf8d --- /dev/null +++ b/frontend/src/components/GiteaRepoBar.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/frontend/src/pages/relay/RepoSettingsPage.vue b/frontend/src/pages/relay/RepoSettingsPage.vue index 255bcf2..45b5069 100644 --- a/frontend/src/pages/relay/RepoSettingsPage.vue +++ b/frontend/src/pages/relay/RepoSettingsPage.vue @@ -5,6 +5,7 @@ import { useRoute, useRouter, RouterLink } from 'vue-router' import AppShell from '@/layouts/AppShell.vue' import RepoHeader from '@/components/RepoHeader.vue' import RepoTabs from '@/components/RepoTabs.vue' +import GiteaRepoBar from '@/components/GiteaRepoBar.vue' import { useRepoStore } from '@/stores/repo.store' import { type Repo, type Visibility } from '@/mock/relay.mock' @@ -181,6 +182,11 @@ function renameRepo() { /> +
@@ -449,6 +455,10 @@ textarea.tarea { height: auto; } +.gitea-bar-spacing { + margin-top: 0.6875rem; +} + .readonly { display: flex; align-items: center; diff --git a/frontend/src/stores/repo.store.ts b/frontend/src/stores/repo.store.ts index 75e9f9b..02fd999 100644 --- a/frontend/src/stores/repo.store.ts +++ b/frontend/src/stores/repo.store.ts @@ -39,6 +39,9 @@ function toRepoView(api: ApiRepo): RepoView { progressPct: prog.pct, progressLevel: prog.level, progressLabel: prog.label, + cloneUrl: api.cloneUrl, + htmlUrl: api.htmlUrl, + giteaMissing: api.giteaMissing, } }