feat: Gitea 프론트 연동(클론바·원격없음 배지)·import-admin 인프라 마무리

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 18:11:16 +09:00
parent b626bbdf6c
commit e2fe089321
9 changed files with 294 additions and 22 deletions
+10 -5
View File
@@ -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
GITEA_TEMPLATE_OWNER=ttipo
GITEA_TEMPLATE_REPO=project-base
# import(역방향 동기화로 가져온, 멤버 0명) 저장소의 기본 owner-admin 으로 지정할 가입 사용자 이메일.
# 동기화는 시스템 작업이라 실행 주체가 없으므로 여기서 지정한다.
# 비우거나 미가입 이메일이면 import 저장소는 멤버 0명으로 남고(초대 불가), 가입 후 다음 동기화에서 지정된다.
REPO_IMPORT_ADMIN_EMAIL=admin@example.com
+10 -5
View File
@@ -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
GITEA_TEMPLATE_OWNER=ttipo
GITEA_TEMPLATE_REPO=project-base
# import(역방향 동기화로 가져온, 멤버 0명) 저장소의 기본 owner-admin 으로 지정할 가입 사용자 이메일.
# 동기화는 시스템 작업이라 실행 주체가 없으므로 여기서 지정한다.
# 비우거나 미가입 이메일이면 import 저장소는 멤버 0명으로 남고(초대 불가), 가입 후 다음 동기화에서 지정된다.
REPO_IMPORT_ADMIN_EMAIL=admin@example.com
+62 -2
View File
@@ -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<Repo>,
@InjectRepository(RepoMember)
private readonly memberRepo: Repository<RepoMember>,
private readonly userService: UserService,
private readonly gitea: GiteaService,
) {}
config: ConfigService,
) {
this.importAdminEmail = (
config.get<string>('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<number> {
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<void> {
const member = this.memberRepo.create({
repo,
user,
roleType: 'admin',
isOwner: true,
});
await this.memberRepo.save(member);
}
// 기존 저장소에 Gitea 연동정보를 채우고 누락표시를 해제 — 변경이 있었으면 true
private backfillLinkage(
repo: Repo,
+8
View File
@@ -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"
-5
View File
@@ -1,5 +0,0 @@
# ==========================================
# 프론트엔드 고유 설정 예시 — 복사해 .env.development 생성
# VITE_API_BASE_URL 은 docker-compose 가 루트 .env 의 BACKEND_API_URL 을
# build arg 로 주입하므로 별도로 작성하지 않는다.
# ==========================================
-5
View File
@@ -1,5 +0,0 @@
# ==========================================
# 프론트엔드 고유 설정 예시 — 복사해 .env.production 생성
# VITE_API_BASE_URL 은 docker-compose 가 루트 .env 의 BACKEND_API_URL 을
# build arg 로 주입하므로 별도로 작성하지 않는다.
# ==========================================
+191
View File
@@ -0,0 +1,191 @@
<script setup lang="ts">
// Gitea 저장소 바 — 클론 주소 복사 + Gitea 웹에서 열기 (미연동 시 렌더 안 함)
import { computed, ref } from 'vue'
interface Props {
cloneUrl?: string | null
htmlUrl?: string | null
}
const props = defineProps<Props>()
// 둘 다 없으면 Gitea 미연동 — 표시하지 않음
const connected = computed(() => !!(props.cloneUrl || props.htmlUrl))
const copied = ref(false)
let copyTimer: ReturnType<typeof setTimeout> | null = null
// 클론 주소 클립보드 복사 — 성공 시 잠시 "복사됨" 표시
async function copyClone() {
if (!props.cloneUrl) return
try {
await navigator.clipboard.writeText(props.cloneUrl)
copied.value = true
if (copyTimer) clearTimeout(copyTimer)
copyTimer = setTimeout(() => (copied.value = false), 1500)
} catch {
// 클립보드 권한 없음 등 — 무시(주소는 화면에 노출되어 있음)
}
}
</script>
<template>
<div
v-if="connected"
class="gitea-bar"
>
<span class="gb-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 17H7A5 5 0 0 1 7 7h2" />
<path d="M15 7h2a5 5 0 0 1 0 10h-2" />
<path d="M8 12h8" />
</svg>
</span>
<code
v-if="cloneUrl"
class="gb-url"
:title="cloneUrl"
>{{ cloneUrl }}</code>
<span
v-else
class="gb-url muted"
>클론 주소 없음</span>
<button
v-if="cloneUrl"
class="gb-btn"
type="button"
:aria-label="copied ? '복사됨' : '클론 주소 복사'"
@click="copyClone"
>
<svg
v-if="!copied"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
<svg
v-else
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
{{ copied ? '복사됨' : '복사' }}
</button>
<a
v-if="htmlUrl"
class="gb-btn open"
:href="htmlUrl"
target="_blank"
rel="noopener noreferrer"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<path d="M15 3h6v6M10 14 21 3" />
</svg>
Gitea에서 열기
</a>
</div>
</template>
<style scoped>
.gitea-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.gb-ico {
color: #1b7a3d;
display: grid;
place-items: center;
flex-shrink: 0;
}
.gb-ico svg {
width: 1rem;
height: 1rem;
}
.gb-url {
flex: 1;
min-width: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.781rem;
color: var(--text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gb-url.muted {
font-family: inherit;
color: var(--text-3);
}
.gb-btn {
display: inline-flex;
align-items: center;
gap: 0.3125rem;
height: 2rem;
padding: 0 0.6875rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: #fff;
color: var(--text-2);
font-family: inherit;
font-size: 0.781rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
text-decoration: none;
}
.gb-btn:hover {
background: #f7f8fa;
color: var(--text);
}
.gb-btn svg {
width: 0.875rem;
height: 0.875rem;
}
.gb-btn.open {
border-color: var(--accent-border);
color: var(--accent);
background: var(--accent-weak);
}
.gb-btn.open:hover {
background: var(--accent-weak);
color: var(--accent);
}
</style>
@@ -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() {
/><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
</span>
</div>
<GiteaRepoBar
:clone-url="repo.cloneUrl"
:html-url="repo.htmlUrl"
class="gitea-bar-spacing"
/>
</div>
<div class="fblock">
@@ -449,6 +455,10 @@ textarea.tarea {
height: auto;
}
.gitea-bar-spacing {
margin-top: 0.6875rem;
}
.readonly {
display: flex;
align-items: center;
+3
View File
@@ -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,
}
}