refactor: 저장소(Repo)·Gitea 연동 기능 완전 제거 — 프로젝트 단독 구조

복잡도를 줄이기 위해 저장소 연동 기능을 전부 제거하고 프로젝트를 단독 단위로 운영한다.

백엔드:
- modules/repo, modules/gitea 모듈 전체 삭제(엔티티/서비스/컨트롤러/DTO/역방향 동기화)
- app.module 에서 RepoModule 제거
- ProjectService: repoCount 집계·repoRepo·"미분류" 시스템 프로젝트(ensureUnassigned/
  onApplicationBootstrap)·isSystem 게이트 전부 제거. ProjectResponse 에서 repoCount/isSystem 제거
- Project 엔티티 is_system 컬럼 제거(dev synchronize 가 DROP)
- ProjectModule forFeature 에서 Repo 제거
- Activity 타입 repo.linked/unlinked 제거
- env example 에서 Gitea/REPO_IMPORT 섹션 제거

프론트:
- ProjectReposPage, useRepo 삭제. ProjectTabs 의 '저장소' 탭·repoCount prop 제거
- 4개 페이지의 :repo-count 제거, 라우트 /projects/:id/repos 제거
- types/project·project.store·mock 에서 repoCount/isSystem·Repo 뷰 제거
- ProjectListPage 저장소수/시스템 배지·gitea CSS 제거, activity 의 repo.* 케이스 제거
- types/repo 는 공용 ApiMember 만 유지
- 잔여 '저장소'/Gitea 사용자 문구를 프로젝트로 정리

검증: 백엔드 build/lint 0·8 tests, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 01:44:15 +09:00
parent 861c8efa6b
commit ef1757181d
34 changed files with 20 additions and 2369 deletions
-2
View File
@@ -13,7 +13,6 @@ import { AppService } from './app.service';
import { HealthModule } from './modules/health/health.module';
import { UserModule } from './modules/user/user.module';
import { AuthModule } from './modules/auth/auth.module';
import { RepoModule } from './modules/repo/repo.module';
import { TaskModule } from './modules/task/task.module';
import { ActivityModule } from './modules/activity/activity.module';
import { NotificationModule } from './modules/notification/notification.module';
@@ -94,7 +93,6 @@ import { ProjectModule } from './modules/project/project.module';
HealthModule,
UserModule,
AuthModule,
RepoModule,
TaskModule,
ActivityModule,
NotificationModule,
@@ -16,9 +16,6 @@ export type ActivityType =
// 프로젝트(설정)
| 'project.created'
| 'project.renamed'
// 저장소 연동
| 'repo.linked'
| 'repo.unlinked'
// 멤버
| 'member.invited'
| 'member.role_changed'
@@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { GiteaService } from './gitea.service';
// Gitea 연동 모듈 — 외부 Gitea 인스턴스의 조직 저장소를 생성/수정/삭제하는 클라이언트 제공
@Module({
providers: [GiteaService],
exports: [GiteaService],
})
export class GiteaModule {}
@@ -1,178 +0,0 @@
import { ConfigService } from '@nestjs/config';
import { GiteaApiError, GiteaService } from './gitea.service';
// ConfigService 간이 스텁 — 주어진 맵에서만 값을 반환
function configStub(values: Record<string, string>): ConfigService {
return {
get: (key: string): string | undefined => values[key],
} as unknown as ConfigService;
}
// fetch 응답 간이 모킹
function mockResponse(status: number, body: unknown): Promise<Response> {
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,
name: 'q3-launch',
full_name: 'marketing-team/q3-launch',
description: 'q3 — 런칭',
private: true,
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,
name: 'q3-launch',
fullName: 'marketing-team/q3-launch',
description: 'q3 — 런칭',
private: true,
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('listOrgRepos', () => {
it('조직 저장소를 페이지네이션 순회하며 매핑한다', async () => {
const page1 = Array.from({ length: 50 }, (_, i) => ({
id: i + 1,
name: `repo-${i + 1}`,
full_name: `marketing-team/repo-${i + 1}`,
description: '',
private: false,
clone_url: `https://gitea.example.com/marketing-team/repo-${i + 1}.git`,
html_url: `https://gitea.example.com/marketing-team/repo-${i + 1}`,
default_branch: 'main',
}));
const page2 = [
{
id: 51,
name: 'repo-51',
full_name: 'marketing-team/repo-51',
description: '표시명 — 설명',
private: true,
clone_url: 'https://gitea.example.com/marketing-team/repo-51.git',
html_url: 'https://gitea.example.com/marketing-team/repo-51',
default_branch: 'develop',
},
];
const fetchMock = jest
.spyOn(global, 'fetch')
.mockReturnValueOnce(mockResponse(200, page1))
.mockReturnValueOnce(mockResponse(200, page2));
const svc = new GiteaService(configStub(fullConfig));
const result = await svc.listOrgRepos();
// 첫 페이지가 한도(50)면 다음 페이지까지 요청
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][0]).toBe(
'https://gitea.example.com/api/v1/orgs/marketing-team/repos?page=1&limit=50',
);
expect(result).toHaveLength(51);
expect(result[50]).toEqual({
id: 51,
name: 'repo-51',
fullName: 'marketing-team/repo-51',
description: '표시명 — 설명',
private: true,
cloneUrl: 'https://gitea.example.com/marketing-team/repo-51.git',
htmlUrl: 'https://gitea.example.com/marketing-team/repo-51',
defaultBranch: 'develop',
});
});
});
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');
});
});
});
-220
View File
@@ -1,220 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
// Gitea 저장소 생성/조회 결과(우리가 보관하는 핵심 필드만 추림)
export interface GiteaRepoResult {
id: number;
name: string; // 저장소 이름(= Relay slugName)
fullName: string; // 'org/slug'
description: string; // 설명(없으면 빈 문자열)
private: boolean; // 비공개 여부
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;
name: string;
full_name: string;
description: string;
private: boolean;
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;
// 템플릿 저장소(이 저장소를 복제해 새 repo 생성). 기본값: TtiPo/project-base
readonly templateOwner: string;
readonly templateRepo: string;
constructor(config: ConfigService) {
this.baseUrl = (config.get<string>('GITEA_BASE_URL') ?? '').replace(
/\/+$/,
'',
);
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})`);
} else {
this.logger.warn(
'Gitea 연동 비활성화 — GITEA_BASE_URL/GITEA_TOKEN/GITEA_ORG 미설정. 저장소는 Gitea 동기화 없이 동작합니다.',
);
}
}
// 필수 설정이 모두 있으면 연동 활성화
get enabled(): boolean {
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
description: string;
private: boolean;
defaultBranch: string;
}): Promise<GiteaRepoResult> {
const data = await this.request<GiteaRepoApi>(
'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);
}
// 템플릿 저장소를 복제해 조직 아래 새 저장소 생성 (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) 는 변경하지 않음.
// default_branch 는 Gitea 에 실제로 존재하는 브랜치여야 한다(없으면 Gitea 가 에러).
async updateRepo(
name: string,
patch: { description?: string; private?: boolean; defaultBranch?: string },
): Promise<void> {
await this.request<GiteaRepoApi>(
'PATCH',
`/repos/${encodeURIComponent(this.org)}/${encodeURIComponent(name)}`,
{
description: patch.description,
private: patch.private,
default_branch: patch.defaultBranch,
},
);
}
// 저장소 삭제
async deleteRepo(name: string): Promise<void> {
await this.request<null>(
'DELETE',
`/repos/${encodeURIComponent(this.org)}/${encodeURIComponent(name)}`,
);
}
// 조직 아래 모든 저장소 조회(페이지네이션 순회) — Gitea → Relay 동기화용
async listOrgRepos(): Promise<GiteaRepoResult[]> {
const limit = 50; // Gitea 기본 최대 페이지 크기
const all: GiteaRepoResult[] = [];
for (let page = 1; ; page++) {
const data = await this.request<GiteaRepoApi[]>(
'GET',
`/orgs/${encodeURIComponent(this.org)}/repos?page=${page}&limit=${limit}`,
);
if (!data || data.length === 0) break;
all.push(...data.map((d) => this.toResult(d)));
// 마지막 페이지(요청 한도 미만)면 종료
if (data.length < limit) break;
}
return all;
}
// --- 내부 헬퍼 ---
// Gitea API 공통 요청 — 토큰 헤더 부착, 실패 시 GiteaApiError
private async request<T>(
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
path: string,
body?: Record<string, unknown>,
): Promise<T> {
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,
name: data.name,
fullName: data.full_name,
description: data.description ?? '',
private: data.private,
cloneUrl: data.clone_url,
htmlUrl: data.html_url,
defaultBranch: data.default_branch,
};
}
}
@@ -24,10 +24,6 @@ export class Project {
@Column({ type: 'varchar', nullable: true })
description!: string | null;
// 시스템 예약 프로젝트 여부 — "미분류"(Gitea import 기본 버킷)는 true. 삭제/이름변경 보호.
@Column({ name: 'is_system', default: false })
isSystem!: boolean;
// 멤버 목록
@OneToMany(() => ProjectMember, (member) => member.project)
members!: Relation<ProjectMember>[];
@@ -3,7 +3,6 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { UserModule } from '../user/user.module';
import { Project } from './entities/project.entity';
import { ProjectMember } from './entities/project-member.entity';
import { Repo } from '../repo/entities/repo.entity';
import { Task } from '../task/entities/task.entity';
import { ActivityModule } from '../activity/activity.module';
import { NotificationModule } from '../notification/notification.module';
@@ -12,11 +11,11 @@ import { ProjectMemberService } from './project-member.service';
import { ProjectController } from './project.controller';
import { ProjectMemberController } from './project-member.controller';
// 프로젝트 모듈 — 작업의 기본 단위. 저장소(Repo)·업무(Task)·활동(Activity)이 프로젝트에 연동된다.
// 프로젝트 모듈 — 작업의 기본 단위. 업무(Task)·활동(Activity)이 프로젝트에 연동된다.
// ProjectService 의 권한 헬퍼(assertProjectMember/Admin)를 다른 모듈이 주입해 사용한다.
@Module({
imports: [
TypeOrmModule.forFeature([Project, ProjectMember, Repo, Task]),
TypeOrmModule.forFeature([Project, ProjectMember, Task]),
UserModule,
ActivityModule,
NotificationModule,
+8 -79
View File
@@ -1,9 +1,7 @@
import {
ForbiddenException,
Injectable,
Logger,
NotFoundException,
type OnApplicationBootstrap,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
@@ -15,23 +13,17 @@ import {
} from '../../common/pagination/pagination';
import { Project } from './entities/project.entity';
import { ProjectMember } from './entities/project-member.entity';
import { Repo } from '../repo/entities/repo.entity';
import { Task } from '../task/entities/task.entity';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
// "미분류" 시스템 프로젝트 이름 — Gitea import 기본 버킷
export const UNASSIGNED_PROJECT_NAME = '미분류';
// 프로젝트 응답 형태 — 파생 라벨/진행률은 프론트에서 계산
export interface ProjectResponse {
id: string;
name: string;
description: string | null;
isSystem: boolean;
members: PublicUser[];
memberCount: number;
repoCount: number; // 연동 저장소 수
doneCount: number; // 완료 업무 수
totalCount: number; // 전체 업무 수
updatedAt: string;
@@ -42,26 +34,17 @@ export interface ProjectResponse {
// 프로젝트 비즈니스 로직 + 권한 헬퍼(다른 모듈이 주입해 사용)
@Injectable()
export class ProjectService implements OnApplicationBootstrap {
private readonly logger = new Logger(ProjectService.name);
export class ProjectService {
constructor(
@InjectRepository(Project)
private readonly projectRepo: Repository<Project>,
@InjectRepository(ProjectMember)
private readonly memberRepo: Repository<ProjectMember>,
@InjectRepository(Repo)
private readonly repoRepo: Repository<Repo>,
@InjectRepository(Task)
private readonly taskRepo: Repository<Task>,
private readonly userService: UserService,
) {}
// 부팅 시 "미분류" 시스템 프로젝트 보장(Gitea import 기본 버킷)
async onApplicationBootstrap(): Promise<void> {
await this.ensureUnassignedProject();
}
// 프로젝트 목록 — 단일 조직 모델상 인증 사용자면 전체 열람. 페이지네이션.
async findAll(
page?: number,
@@ -70,17 +53,14 @@ export class ProjectService implements OnApplicationBootstrap {
const pg = resolvePage(page, size);
const [projects, total] = await this.projectRepo.findAndCount({
relations: ['members', 'members.user'],
order: { isSystem: 'DESC', updatedAt: 'DESC' },
order: { updatedAt: 'DESC' },
skip: pg.skip,
take: pg.take,
});
const ids = projects.map((p) => p.id);
const [repoMap, taskMap] = await Promise.all([
this.repoCountsByProject(ids),
this.taskCountsByProject(ids),
]);
const taskMap = await this.taskCountsByProject(ids);
return paginated(
projects.map((p) => this.toResponse(p, repoMap, taskMap)),
projects.map((p) => this.toResponse(p, taskMap)),
total,
pg,
);
@@ -98,11 +78,8 @@ export class ProjectService implements OnApplicationBootstrap {
const membership = await this.memberRepo.findOne({
where: { project: { id: project.id }, user: { id: userId } },
});
const [repoMap, taskMap] = await Promise.all([
this.repoCountsByProject([project.id]),
this.taskCountsByProject([project.id]),
]);
const base = this.toResponse(project, repoMap, taskMap);
const taskMap = await this.taskCountsByProject([project.id]);
const base = this.toResponse(project, taskMap);
return {
...base,
canManage: membership?.roleType === 'admin',
@@ -119,7 +96,6 @@ export class ProjectService implements OnApplicationBootstrap {
this.projectRepo.create({
name: dto.name,
description: dto.description ?? null,
isSystem: false,
}),
);
const creator = await this.userService.findById(userId);
@@ -136,7 +112,7 @@ export class ProjectService implements OnApplicationBootstrap {
return this.findOne(project.id, userId);
}
// 프로젝트 수정 — admin 권한. 시스템 프로젝트는 이름 변경 불가.
// 프로젝트 수정 — admin 권한.
async update(
projectId: string,
userId: string,
@@ -144,20 +120,13 @@ export class ProjectService implements OnApplicationBootstrap {
): Promise<ProjectResponse> {
const project = await this.getProjectOrThrow(projectId);
await this.assertProjectAdmin(project.id, userId);
if (
project.isSystem &&
dto.name !== undefined &&
dto.name !== project.name
) {
throw new ForbiddenException();
}
if (dto.name !== undefined) project.name = dto.name;
if (dto.description !== undefined) project.description = dto.description;
await this.projectRepo.save(project);
return this.findOne(project.id, userId);
}
// 프로젝트 삭제 — 소유자 권한. 시스템 프로젝트는 삭제 불가.
// 프로젝트 삭제 — 소유자 권한.
async remove(projectId: string, userId: string): Promise<void> {
const project = await this.getProjectOrThrow(projectId);
const membership = await this.memberRepo.findOne({
@@ -166,31 +135,11 @@ export class ProjectService implements OnApplicationBootstrap {
if (!membership?.isOwner) {
throw new ForbiddenException();
}
if (project.isSystem) {
throw new ForbiddenException();
}
await this.projectRepo.remove(project);
}
// --- 다른 모듈에서 쓰는 헬퍼 ---
// "미분류" 시스템 프로젝트 보장 후 id 반환 (Gitea import 연결용)
async ensureUnassignedProject(): Promise<Project> {
const existing = await this.projectRepo.findOne({
where: { isSystem: true },
});
if (existing) return existing;
const created = await this.projectRepo.save(
this.projectRepo.create({
name: UNASSIGNED_PROJECT_NAME,
description: 'Gitea 에서 가져온, 아직 프로젝트에 배정되지 않은 저장소',
isSystem: true,
}),
);
this.logger.log('"미분류" 시스템 프로젝트 생성');
return created;
}
// projectId 로 프로젝트 로딩, 없으면 404
async getProjectOrThrow(projectId: string): Promise<Project> {
const project = await this.projectRepo.findOne({
@@ -222,23 +171,6 @@ export class ProjectService implements OnApplicationBootstrap {
}
}
// 프로젝트별 연동 저장소 수 — 목록/단건 진행 표시용
private async repoCountsByProject(
ids: string[],
): Promise<Map<string, number>> {
const map = new Map<string, number>();
if (ids.length === 0) return map;
const rows = await this.repoRepo
.createQueryBuilder('repo')
.select('repo.project_id', 'projectId')
.addSelect('COUNT(*)', 'count')
.where('repo.project_id IN (:...ids)', { ids })
.groupBy('repo.project_id')
.getRawMany<{ projectId: string; count: string }>();
for (const r of rows) map.set(r.projectId, Number(r.count));
return map;
}
// 프로젝트별 업무 완료/전체 수 — 진행률 집계용
private async taskCountsByProject(
ids: string[],
@@ -262,7 +194,6 @@ export class ProjectService implements OnApplicationBootstrap {
// 엔티티 → 응답 매핑. 카운트 맵에서 프로젝트별 집계를 주입한다.
private toResponse(
project: Project,
repoMap: Map<string, number>,
taskMap: Map<string, { done: number; total: number }>,
): ProjectResponse {
const members = project.members ?? [];
@@ -271,12 +202,10 @@ export class ProjectService implements OnApplicationBootstrap {
id: project.id,
name: project.name,
description: project.description,
isSystem: project.isSystem,
members: members
.filter((m) => m.user)
.map((m) => UserService.toPublic(m.user)),
memberCount: members.length,
repoCount: repoMap.get(project.id) ?? 0,
doneCount: counts?.done ?? 0,
totalCount: counts?.total ?? 0,
updatedAt: project.updatedAt.toISOString(),
@@ -1,58 +0,0 @@
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 저장소 생성 DTO
export class CreateRepoDto {
@ApiProperty({
description: '저장소 slug(영문 소문자·숫자·하이픈·밑줄)',
example: 'q3-launch-campaign',
})
@IsString()
@IsNotEmpty({ message: '저장소 이름은 필수 입력 항목입니다.' })
@Matches(/^[a-z0-9-_]+$/, {
message:
'저장소 이름은 영문 소문자·숫자와 하이픈(-)·밑줄(_)만 사용할 수 있습니다.',
})
@MaxLength(60)
slug!: string;
@ApiProperty({
description: '표시 제목(한글)',
example: '3분기 신제품 런칭 캠페인',
})
@IsString()
@IsNotEmpty({ message: '표시 제목은 필수 입력 항목입니다.' })
@MaxLength(40)
name!: string;
@ApiProperty({
description: '프로젝트 설명(필수)',
example: '3분기 신제품 런칭을 위한 마케팅 캠페인 저장소',
})
@IsString()
@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;
}
@@ -1,22 +0,0 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 저장소 수정 DTO — 표시 제목/설명/브랜치 (slug·owner 는 변경 불가, 공개범위는 Gitea 기준)
export class UpdateRepoDto {
@ApiProperty({ description: '표시 제목(한글)', required: false })
@IsOptional()
@IsString()
@MaxLength(40)
name?: string;
@ApiProperty({ description: '프로젝트 설명', required: false })
@IsOptional()
@IsString()
@MaxLength(300)
description?: string;
@ApiProperty({ description: '메인 브랜치', required: false })
@IsOptional()
@IsString()
branch?: string;
}
@@ -1,82 +0,0 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { Project } from '../../project/entities/project.entity';
// 저장소 공개 범위
export type RepoVisibility = 'private' | 'public';
// 저장소 엔티티 — 프로젝트에 연동되는 Gitea 저장소(코드 연동 전용). 멤버십/권한은 소유 프로젝트가 결정.
@Entity('repos')
export class Repo {
// 내부 식별자 (UUID)
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 프로젝트 (프로젝트 삭제 시 함께 삭제)
@Index()
@ManyToOne(() => Project, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'project_id' })
project!: Relation<Project>;
// 라우팅/URL 식별자(영문 slug 세그먼트, 예: 'q3-launch-campaign') — Gitea 조직 내 유일 → 전역 유일
@Column({ name: 'slug_name', unique: true })
slugName!: string;
// 소유 조직 slug (예: 'marketing-team') — 현재는 단일 조직 고정
@Column()
owner!: string;
// 표시 제목(한글, 예: '3분기 신제품 런칭 캠페인')
@Column()
name!: string;
// 프로젝트 설명(선택)
@Column({ type: 'varchar', nullable: true })
description!: string | null;
// 공개 범위
@Column({ type: 'varchar', default: 'private' })
visibility!: RepoVisibility;
// 메인 브랜치
@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;
// Gitea 조직에서 더 이상 발견되지 않는 저장소 표시(동기화로 갱신).
// 연동 origin(giteaRepoId 보유)이 아닌 저장소는 항상 false 유지.
@Column({ name: 'gitea_missing', default: false })
giteaMissing!: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
@@ -1,206 +0,0 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { GiteaService, type GiteaRepoResult } from '../gitea/gitea.service';
import { ProjectService } from '../project/project.service';
import { Repo } from './entities/repo.entity';
// Gitea description 에서 분리한 표시명/설명
interface ParsedDescription {
name: string;
desc: string | null;
}
// Gitea 조직 저장소를 Relay 로 가져오는 동기화 서비스.
// - 서버 기동 직후 1회 + 매 10분마다 조직의 저장소 목록을 받아 Relay 와 맞춘다.
// - 추가형(additive): Gitea 에만 있는 저장소는 "미분류" 프로젝트로 import, 양쪽에 있으면 연동정보 backfill.
// - Gitea 에서 사라진(연동 origin) 저장소는 삭제하지 않고 giteaMissing 플래그로 표기만 한다.
// - 멤버십은 프로젝트 단위 — import 저장소는 "미분류" 프로젝트에 연결되고, 관리자가 적절한 프로젝트로 이동한다.
@Injectable()
export class RepoSyncService implements OnApplicationBootstrap {
private readonly logger = new Logger(RepoSyncService.name);
// 동기화 중복 실행 방지(부트스트랩과 스케줄이 겹치는 경우)
private running = false;
constructor(
@InjectRepository(Repo)
private readonly repoRepo: Repository<Repo>,
private readonly gitea: GiteaService,
private readonly projectService: ProjectService,
) {}
// 서버 기동 직후 1회 동기화 — 부팅을 막지 않도록 비차단(백그라운드)으로 실행
onApplicationBootstrap(): void {
void this.syncFromGitea('부트스트랩');
}
// 10분마다 동기화
@Cron(CronExpression.EVERY_10_MINUTES)
async handleScheduledSync(): Promise<void> {
await this.syncFromGitea('스케줄(10분)');
}
// 조직 저장소 동기화 본체 — 실패해도 예외를 전파하지 않고 로그만 남긴다.
async syncFromGitea(trigger: string): Promise<void> {
if (!this.gitea.enabled) {
this.logger.debug(`Gitea 미연동 — 동기화 건너뜀 (${trigger})`);
return;
}
if (this.running) {
this.logger.warn(`이전 동기화가 진행 중 — 건너뜀 (${trigger})`);
return;
}
this.running = true;
try {
const remote = await this.gitea.listOrgRepos();
const existing = await this.repoRepo.find();
// import 대상 기본 프로젝트("미분류")
const unassigned = await this.projectService.ensureUnassignedProject();
// 매칭용 인덱스: Gitea ID 우선, 없으면 slugName 으로 매칭
const byGiteaId = new Map<number, Repo>();
const bySlug = new Map<string, Repo>();
for (const r of existing) {
if (r.giteaRepoId !== null) byGiteaId.set(r.giteaRepoId, r);
bySlug.set(r.slugName, r);
}
const seenGiteaIds = new Set<number>();
// 이번 동기화에서 이미 매칭된 Relay 저장소(uuid) — 중복 매칭 방지
const matchedRepoIds = new Set<string>();
let imported = 0;
let relinked = 0;
for (const gr of remote) {
seenGiteaIds.add(gr.id);
// Gitea ID 우선, 없으면 옛 slug 로 매칭. 단 이미 매칭된 저장소는 제외
// (Gitea 에서 rename 후 옛 이름으로 새 저장소를 만든 경우의 stale-slug 충돌 방지)
const candidate = byGiteaId.get(gr.id) ?? bySlug.get(gr.name);
const match =
candidate && !matchedRepoIds.has(candidate.id) ? candidate : null;
if (match) {
matchedRepoIds.add(match.id);
// Gitea 를 SSOT 로 — 이름(slug)/표시명/설명/공개범위/브랜치 + 연동정보를 Gitea 기준에 맞춘다.
// (Gitea 에서 직접 변경한 내용을 Relay 로 끌어온다. rename 시 slugName 도 따라가 라우팅·쓰기 동기화 일치)
if (this.reconcileFromGitea(match, gr)) {
await this.repoRepo.save(match);
relinked++;
}
} else {
// Gitea 에만 있는 저장소 — "미분류" 프로젝트로 가져오기
const { name, desc } = this.parseDescription(gr.description, gr.name);
const repo = this.repoRepo.create({
project: unassigned,
slugName: gr.name,
owner: this.gitea.org,
name,
description: desc,
visibility: gr.private ? 'private' : 'public',
branch: gr.defaultBranch || 'main',
giteaRepoId: gr.id,
giteaFullName: gr.fullName,
cloneUrl: gr.cloneUrl,
htmlUrl: gr.htmlUrl,
giteaMissing: false,
});
await this.repoRepo.save(repo);
imported++;
}
}
// Gitea 에서 사라진(연동 origin) 저장소는 삭제하지 않고 표기만 한다.
let missing = 0;
for (const r of existing) {
if (
r.giteaRepoId !== null &&
!seenGiteaIds.has(r.giteaRepoId) &&
!r.giteaMissing
) {
r.giteaMissing = true;
await this.repoRepo.save(r);
missing++;
}
}
this.logger.log(
`Gitea 동기화 완료 (${trigger}) — 원격 ${remote.length}개 / 신규 ${imported} · 갱신 ${relinked} · 누락표시 ${missing}`,
);
} catch (e) {
this.logger.error(
`Gitea 동기화 실패 (${trigger})`,
e instanceof Error ? e.stack : String(e),
);
} finally {
this.running = false;
}
}
// Gitea 를 기준으로 Relay 저장소 메타를 맞춘다 — Gitea 의 네이티브 필드와 깔끔히 1:1 대응되는
// 이름(slug)/공개범위/기본 브랜치 + 연동정보만 동기화한다. 변경이 있었으면 true.
// ※ 표시명/설명은 Gitea 단일 description 에 "표시제목 — 설명" 으로 합쳐 저장하므로,
// Gitea 에서 description 을 직접 편집하면 표시명이 깨질 수 있어 Relay 가 SSOT 로 유지한다.
private reconcileFromGitea(repo: Repo, gr: GiteaRepoResult): boolean {
let changed = false;
// 이름(slug) — Gitea rename 반영. Relay 라우팅 URL 과 이후 Gitea 쓰기 경로가 새 이름을 따른다
if (repo.slugName !== gr.name) {
repo.slugName = gr.name;
changed = true;
}
// 공개범위
const visibility = gr.private ? 'private' : 'public';
if (repo.visibility !== visibility) {
repo.visibility = visibility;
changed = true;
}
// 기본 브랜치
if (gr.defaultBranch && repo.branch !== gr.defaultBranch) {
repo.branch = gr.defaultBranch;
changed = true;
}
// 연동정보(linkage) + 누락표시 해제
if (repo.giteaRepoId !== gr.id) {
repo.giteaRepoId = gr.id;
changed = true;
}
if (repo.giteaFullName !== gr.fullName) {
repo.giteaFullName = gr.fullName;
changed = true;
}
if (repo.cloneUrl !== gr.cloneUrl) {
repo.cloneUrl = gr.cloneUrl;
changed = true;
}
if (repo.htmlUrl !== gr.htmlUrl) {
repo.htmlUrl = gr.htmlUrl;
changed = true;
}
if (repo.giteaMissing) {
repo.giteaMissing = false;
changed = true;
}
return changed;
}
// Gitea description("표시제목 — 설명") 에서 표시명/설명 분리.
// Relay 생성 저장소는 buildGiteaDescription 규칙을 따르고,
// 구분자가 없는 Gitea-native 저장소는 slug 를 표시명으로 사용한다.
private parseDescription(
description: string,
slug: string,
): ParsedDescription {
const sep = ' — ';
const d = (description ?? '').trim();
if (!d) return { name: slug, desc: null };
const idx = d.indexOf(sep);
if (idx > 0) {
return {
name: d.slice(0, idx).trim(),
desc: d.slice(idx + sep.length).trim() || null,
};
}
return { name: slug, desc: d };
}
}
-117
View File
@@ -1,117 +0,0 @@
import {
Body,
Controller,
DefaultValuePipe,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
ParseUUIDPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import { RepoService, type RepoResponse } from './repo.service';
import type { PaginatedResult } from '../../common/pagination/pagination';
import { CreateRepoDto } from './dto/create-repo.dto';
import { UpdateRepoDto } from './dto/update-repo.dto';
// 저장소 컨트롤러 — 프로젝트에 연동된 저장소. 모든 엔드포인트 인증 필요.
@ApiTags('Repo')
@Controller('projects/:projectId/repos')
@UseGuards(JwtAuthGuard)
export class RepoController {
constructor(private readonly repoService: RepoService) {}
@Get()
@ApiOperation({ summary: '프로젝트 연동 저장소 목록 (페이지네이션)' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
findAll(
@Param('projectId', ParseUUIDPipe) projectId: string,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
): Promise<PaginatedResult<RepoResponse>> {
return this.repoService.findAll(projectId, page, size);
}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '저장소 생성·연동 (admin)' })
@ApiResponse({ status: 201, description: '생성 성공' })
@ApiResponse({
status: 409,
description: '이미 사용 중인 저장소 이름 (BIZ_001)',
})
create(
@Param('projectId', ParseUUIDPipe) projectId: string,
@Body() dto: CreateRepoDto,
@CurrentUser() user: PublicUser,
): Promise<RepoResponse> {
return this.repoService.create(projectId, dto, user.id);
}
// ':repoId' 보다 먼저 — 생성 폼 메타(소유자/템플릿)
@Get('meta')
@ApiOperation({ summary: '저장소 생성 폼 메타(소유자/템플릿) 조회' })
@ApiResponse({ status: 200, description: '조회 성공' })
getCreateMeta(): {
owner: string;
template: { available: boolean; slug: string };
} {
return this.repoService.getCreateMeta();
}
// ':repoId' 보다 먼저 — 생성 폼의 이름(slug) 실시간 가용성 체크
@Get('name-available')
@ApiOperation({ summary: '저장소 이름(slug) 사용 가능 여부' })
@ApiResponse({ status: 200, description: '확인 성공' })
checkName(@Query('slug') slug: string): Promise<{ available: boolean }> {
return this.repoService.isNameAvailable(slug ?? '');
}
@Get(':repoId')
@ApiOperation({ summary: '저장소 단건 조회' })
@ApiResponse({ status: 200, description: '조회 성공' })
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
findOne(
@Param('projectId', ParseUUIDPipe) projectId: string,
@Param('repoId') repoId: string,
@CurrentUser() user: PublicUser,
): Promise<RepoResponse> {
return this.repoService.findOne(projectId, repoId, user.id);
}
@Patch(':repoId')
@ApiOperation({ summary: '저장소 수정 (admin)' })
@ApiResponse({ status: 200, description: '수정 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
update(
@Param('projectId', ParseUUIDPipe) projectId: string,
@Param('repoId') repoId: string,
@Body() dto: UpdateRepoDto,
@CurrentUser() user: PublicUser,
): Promise<RepoResponse> {
return this.repoService.update(projectId, repoId, user.id, dto);
}
@Delete(':repoId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '저장소 삭제·연동 해제 (admin)' })
@ApiResponse({ status: 200, description: '삭제 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
async remove(
@Param('projectId', ParseUUIDPipe) projectId: string,
@Param('repoId') repoId: string,
@CurrentUser() user: PublicUser,
): Promise<null> {
await this.repoService.remove(projectId, repoId, user.id);
return null;
}
}
-24
View File
@@ -1,24 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { GiteaModule } from '../gitea/gitea.module';
import { ActivityModule } from '../activity/activity.module';
import { ProjectModule } from '../project/project.module';
import { Repo } from './entities/repo.entity';
import { RepoService } from './repo.service';
import { RepoController } from './repo.controller';
import { RepoSyncService } from './repo-sync.service';
// 저장소 모듈 — 프로젝트에 연동되는 Gitea 저장소 + Gitea 양방향 동기화.
// 권한은 ProjectService(프로젝트 멤버십)가 결정한다.
@Module({
imports: [
TypeOrmModule.forFeature([Repo]),
GiteaModule,
ActivityModule,
ProjectModule,
],
controllers: [RepoController],
providers: [RepoService, RepoSyncService],
exports: [RepoService],
})
export class RepoModule {}
-382
View File
@@ -1,382 +0,0 @@
import {
HttpStatus,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BusinessException } from '../../common/exceptions/business.exception';
import {
GiteaApiError,
GiteaService,
type GiteaRepoResult,
} from '../gitea/gitea.service';
import { ProjectService } from '../project/project.service';
import { ActivityService } from '../activity/activity.service';
import {
paginated,
resolvePage,
type PaginatedResult,
} from '../../common/pagination/pagination';
import { Repo, type RepoVisibility } from './entities/repo.entity';
import { CreateRepoDto } from './dto/create-repo.dto';
import { UpdateRepoDto } from './dto/update-repo.dto';
// Gitea 연동 비활성 시 사용할 기본 소유자
const DEFAULT_OWNER = 'marketing-team';
// Gitea 저장소 이름 충돌 시 반환되는 HTTP 상태
const HTTP_CONFLICT = 409;
// 저장소 응답 형태 — 프로젝트에 연동된 Gitea 저장소(코드 연동 전용). 멤버/업무 카운트는 프로젝트가 보유.
export interface RepoResponse {
id: string; // slugName (라우팅 식별자)
projectId: string; // 소속 프로젝트
name: string;
owner: string;
slug: string; // owner/slugName
desc: string | null;
branch: string;
cloneUrl: string | null;
htmlUrl: string | null;
giteaMissing: boolean;
updatedAt: string;
// 단건 조회에서만 — 현재 사용자의 소속 프로젝트 권한
canManage?: boolean; // 프로젝트 admin
isOwner?: boolean; // 프로젝트 소유자
}
// 저장소 비즈니스 로직 — 저장소는 프로젝트에 연동되며 권한은 프로젝트가 결정한다.
@Injectable()
export class RepoService {
private readonly logger = new Logger(RepoService.name);
constructor(
@InjectRepository(Repo)
private readonly repoRepo: Repository<Repo>,
private readonly gitea: GiteaService,
private readonly projectService: ProjectService,
private readonly activity: ActivityService,
) {}
// 저장소 생성 폼 메타데이터 — 소유자(조직) 고정 표시 + 템플릿 가용 정보
getCreateMeta(): {
owner: string;
template: { available: boolean; slug: string };
} {
return {
owner: this.gitea.enabled ? this.gitea.org : DEFAULT_OWNER,
template: {
available: this.gitea.templateEnabled,
slug: this.gitea.templateSlug,
},
};
}
// 프로젝트의 연동 저장소 목록 — 인증 사용자면 열람. 페이지네이션.
async findAll(
projectId: string,
page?: number,
size?: number,
): Promise<PaginatedResult<RepoResponse>> {
await this.projectService.getProjectOrThrow(projectId);
const pg = resolvePage(page, size);
const [repos, total] = await this.repoRepo.findAndCount({
where: { project: { id: projectId } },
relations: ['project'],
order: { updatedAt: 'DESC' },
skip: pg.skip,
take: pg.take,
});
return paginated(
repos.map((r) => this.toResponse(r)),
total,
pg,
);
}
// 저장소 이름(slug) 사용 가능 여부 — Gitea 조직 내 유일이므로 전역 중복만 검사
async isNameAvailable(slug: string): Promise<{ available: boolean }> {
const trimmed = (slug ?? '').trim();
if (!trimmed) return { available: false };
const existing = await this.repoRepo.findOne({
where: { slugName: trimmed },
});
return { available: !existing };
}
// 저장소 단건 — 프로젝트 내 저장소. 현재 사용자의 프로젝트 권한(canManage/isOwner) 포함.
async findOne(
projectId: string,
slugName: string,
userId: string,
): Promise<RepoResponse> {
const repo = await this.getEntityOrThrow(projectId, slugName);
const perms = await this.projectPerms(projectId, userId);
return this.toResponse(repo, perms);
}
// 저장소 생성 — 프로젝트 admin 권한. Gitea 조직에 실제 repo 생성 후 프로젝트에 연동.
async create(
projectId: string,
dto: CreateRepoDto,
userId: string,
): Promise<RepoResponse> {
const project = await this.projectService.getProjectOrThrow(projectId);
await this.projectService.assertProjectAdmin(projectId, userId);
const existing = await this.repoRepo.findOne({
where: { slugName: dto.slug },
});
if (existing) {
throw new BusinessException(
'BIZ_001',
'이미 사용 중인 저장소 이름입니다.',
HttpStatus.CONFLICT,
);
}
const description = dto.description;
// 공개 범위는 사용자 입력에서 제거됨 — 새 저장소는 공개(public)로 생성
const visibility: RepoVisibility = 'public';
const requestedBranch = dto.branch?.trim() || 'main';
const owner = this.gitea.enabled ? this.gitea.org : DEFAULT_OWNER;
let giteaRepo: GiteaRepoResult | null = null;
if (this.gitea.enabled) {
giteaRepo = await this.createGiteaRepo(dto.slug, {
name: dto.name,
description,
visibility,
branch: requestedBranch,
useTemplate: dto.useTemplate ?? false,
});
}
const branch = giteaRepo?.defaultBranch || requestedBranch;
try {
const saved = await this.repoRepo.save(
this.repoRepo.create({
project,
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,
}),
);
await this.activity.record({
projectId,
actorId: userId,
type: 'repo.linked',
payload: { repoName: saved.name },
});
return this.toResponse(saved);
} 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 권한
async update(
projectId: string,
slugName: string,
userId: string,
dto: UpdateRepoDto,
): Promise<RepoResponse> {
const repo = await this.getEntityOrThrow(projectId, slugName);
await this.projectService.assertProjectAdmin(projectId, userId);
const prevName = repo.name;
const prevDescription = repo.description ?? '';
const prevBranch = repo.branch;
if (dto.name !== undefined) repo.name = dto.name;
if (dto.description !== undefined) repo.description = dto.description;
if (dto.branch !== undefined && dto.branch.trim())
repo.branch = dto.branch.trim();
const nameChanged = repo.name !== prevName;
const descriptionChanged = (repo.description ?? '') !== prevDescription;
const branchChanged = repo.branch !== prevBranch;
await this.repoRepo.save(repo);
// Gitea 메타데이터 동기화 (실제로 바뀐 경우에만). 공개 범위는 Gitea 기준이라 푸시하지 않음.
const giteaDescChanged = nameChanged || descriptionChanged;
if (
this.gitea.enabled &&
repo.giteaRepoId !== null &&
(giteaDescChanged || branchChanged)
) {
await this.gitea
.updateRepo(repo.slugName, {
description: giteaDescChanged
? this.buildGiteaDescription(repo.name, repo.description)
: undefined,
defaultBranch: branchChanged ? repo.branch : undefined,
})
.catch((e: unknown) => {
this.logger.error(
`Gitea 저장소 수정 동기화 실패: ${repo.slugName}`,
e instanceof Error ? e.stack : String(e),
);
});
}
const perms = await this.projectPerms(projectId, userId);
return this.toResponse(repo, perms);
}
// 저장소 삭제(연동 해제) — 프로젝트 admin 권한
async remove(
projectId: string,
slugName: string,
userId: string,
): Promise<void> {
const repo = await this.getEntityOrThrow(projectId, slugName);
await this.projectService.assertProjectAdmin(projectId, userId);
const repoName = repo.name;
await this.repoRepo.remove(repo);
await this.activity.record({
projectId,
actorId: userId,
type: 'repo.unlinked',
payload: { repoName },
});
// Gitea 저장소도 삭제 (베스트 에포트)
if (this.gitea.enabled && repo.giteaRepoId !== null) {
await this.gitea.deleteRepo(slugName).catch((e: unknown) => {
this.logger.error(
`Gitea 저장소 삭제 동기화 실패: ${slugName}`,
e instanceof Error ? e.stack : String(e),
);
});
}
}
// --- 내부 헬퍼 ---
// 현재 사용자의 프로젝트 권한(canManage/isOwner) — 단건 응답용
private async projectPerms(
projectId: string,
userId: string,
): Promise<{ canManage: boolean; isOwner: boolean }> {
const project = await this.projectService.findOne(projectId, userId);
return {
canManage: project.canManage ?? false,
isOwner: project.isOwner ?? false,
};
}
// Gitea 조직에 저장소 생성 — 실패 시 BusinessException 으로 변환
private async createGiteaRepo(
slug: string,
meta: {
name: string;
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,
private: isPrivate,
defaultBranch: meta.branch,
});
} catch (err) {
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 저장소 설명 문자열 — '표시제목 — 설명' 으로 합친다
private buildGiteaDescription(
name: string,
description: string | null,
): string {
return description ? `${name}${description}` : name;
}
// 프로젝트 내 slugName 으로 저장소 로딩, 없으면 404
private async getEntityOrThrow(
projectId: string,
slugName: string,
): Promise<Repo> {
const repo = await this.repoRepo.findOne({
where: { slugName, project: { id: projectId } },
relations: ['project'],
});
if (!repo) {
throw new NotFoundException();
}
return repo;
}
// 엔티티 → 응답 매핑. perms 는 단건 조회에서만 채운다.
private toResponse(
repo: Repo,
perms?: { canManage: boolean; isOwner: boolean },
): RepoResponse {
return {
id: repo.slugName,
projectId: repo.project?.id ?? '',
name: repo.name,
owner: repo.owner,
slug: `${repo.owner}/${repo.slugName}`,
desc: repo.description,
branch: repo.branch,
cloneUrl: repo.cloneUrl,
htmlUrl: repo.htmlUrl,
giteaMissing: repo.giteaMissing,
updatedAt: repo.updatedAt.toISOString(),
canManage: perms?.canManage,
isOwner: perms?.isOwner,
};
}
}