diff --git a/backend/.env.development.example b/backend/.env.development.example index afd13b4..8bd1315 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -31,23 +31,6 @@ GOOGLE_CLIENT_ID=change-me-google-client-id GOOGLE_CLIENT_SECRET=change-me-google-client-secret GOOGLE_CALLBACK_URL=http://localhost:3100/api/auth/google/callback -# --- Gitea 연동 (외부 Gitea 인스턴스) --- -# 세 값(BASE_URL/TOKEN/ORG)이 모두 채워져야 연동 활성화. 비우면 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 -# 템플릿 저장소(생성 시 복제) — 미설정 시 기본 TtiPo/project-base 사용. Gitea 에서 template 설정 필요 -GITEA_TEMPLATE_OWNER=ttipo -GITEA_TEMPLATE_REPO=project-base -# import(역방향 동기화로 가져온, 멤버 0명) 저장소의 기본 owner-admin 으로 지정할 가입 사용자 이메일. -# 동기화는 시스템 작업이라 실행 주체가 없으므로 여기서 지정한다. -# 비우거나 미가입 이메일이면 import 저장소는 멤버 0명으로 남고(초대 불가), 가입 후 다음 동기화에서 지정된다. -REPO_IMPORT_ADMIN_EMAIL=admin@example.com - # --- AI 에이전트 (Claude) --- # Claude(Anthropic) API 키 — AI 채팅 패널용. 미설정 시 AI 기능 비활성. CLAUDE_API_KEY=change-me-claude-api-key diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 1806eff..74adf22 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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, diff --git a/backend/src/modules/activity/entities/activity.entity.ts b/backend/src/modules/activity/entities/activity.entity.ts index f8d8da0..f9c59f5 100644 --- a/backend/src/modules/activity/entities/activity.entity.ts +++ b/backend/src/modules/activity/entities/activity.entity.ts @@ -16,9 +16,6 @@ export type ActivityType = // 프로젝트(설정) | 'project.created' | 'project.renamed' - // 저장소 연동 - | 'repo.linked' - | 'repo.unlinked' // 멤버 | 'member.invited' | 'member.role_changed' diff --git a/backend/src/modules/gitea/gitea.module.ts b/backend/src/modules/gitea/gitea.module.ts deleted file mode 100644 index eb6c90a..0000000 --- a/backend/src/modules/gitea/gitea.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index c417a8f..0000000 --- a/backend/src/modules/gitea/gitea.service.spec.ts +++ /dev/null @@ -1,178 +0,0 @@ -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, - 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'); - }); - }); -}); diff --git a/backend/src/modules/gitea/gitea.service.ts b/backend/src/modules/gitea/gitea.service.ts deleted file mode 100644 index b46a48b..0000000 --- a/backend/src/modules/gitea/gitea.service.ts +++ /dev/null @@ -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('GITEA_BASE_URL') ?? '').replace( - /\/+$/, - '', - ); - 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})`); - } 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 { - 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); - } - - // 템플릿 저장소를 복제해 조직 아래 새 저장소 생성 (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) 는 변경하지 않음. - // default_branch 는 Gitea 에 실제로 존재하는 브랜치여야 한다(없으면 Gitea 가 에러). - async updateRepo( - name: string, - patch: { description?: string; private?: boolean; defaultBranch?: string }, - ): Promise { - await this.request( - 'PATCH', - `/repos/${encodeURIComponent(this.org)}/${encodeURIComponent(name)}`, - { - description: patch.description, - private: patch.private, - default_branch: patch.defaultBranch, - }, - ); - } - - // 저장소 삭제 - async deleteRepo(name: string): Promise { - await this.request( - 'DELETE', - `/repos/${encodeURIComponent(this.org)}/${encodeURIComponent(name)}`, - ); - } - - // 조직 아래 모든 저장소 조회(페이지네이션 순회) — Gitea → Relay 동기화용 - async listOrgRepos(): Promise { - const limit = 50; // Gitea 기본 최대 페이지 크기 - const all: GiteaRepoResult[] = []; - for (let page = 1; ; page++) { - const data = await this.request( - '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( - 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, - 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, - }; - } -} diff --git a/backend/src/modules/project/entities/project.entity.ts b/backend/src/modules/project/entities/project.entity.ts index ff13b58..68f7969 100644 --- a/backend/src/modules/project/entities/project.entity.ts +++ b/backend/src/modules/project/entities/project.entity.ts @@ -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[]; diff --git a/backend/src/modules/project/project.module.ts b/backend/src/modules/project/project.module.ts index f3cc238..8003847 100644 --- a/backend/src/modules/project/project.module.ts +++ b/backend/src/modules/project/project.module.ts @@ -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, diff --git a/backend/src/modules/project/project.service.ts b/backend/src/modules/project/project.service.ts index 69189bb..de92888 100644 --- a/backend/src/modules/project/project.service.ts +++ b/backend/src/modules/project/project.service.ts @@ -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, @InjectRepository(ProjectMember) private readonly memberRepo: Repository, - @InjectRepository(Repo) - private readonly repoRepo: Repository, @InjectRepository(Task) private readonly taskRepo: Repository, private readonly userService: UserService, ) {} - // 부팅 시 "미분류" 시스템 프로젝트 보장(Gitea import 기본 버킷) - async onApplicationBootstrap(): Promise { - 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 { 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 { 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 { - 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 { const project = await this.projectRepo.findOne({ @@ -222,23 +171,6 @@ export class ProjectService implements OnApplicationBootstrap { } } - // 프로젝트별 연동 저장소 수 — 목록/단건 진행 표시용 - private async repoCountsByProject( - ids: string[], - ): Promise> { - const map = new Map(); - 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, taskMap: Map, ): 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(), diff --git a/backend/src/modules/repo/dto/create-repo.dto.ts b/backend/src/modules/repo/dto/create-repo.dto.ts deleted file mode 100644 index e6190bc..0000000 --- a/backend/src/modules/repo/dto/create-repo.dto.ts +++ /dev/null @@ -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; -} diff --git a/backend/src/modules/repo/dto/update-repo.dto.ts b/backend/src/modules/repo/dto/update-repo.dto.ts deleted file mode 100644 index a2a4aa6..0000000 --- a/backend/src/modules/repo/dto/update-repo.dto.ts +++ /dev/null @@ -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; -} diff --git a/backend/src/modules/repo/entities/repo.entity.ts b/backend/src/modules/repo/entities/repo.entity.ts deleted file mode 100644 index 35989cd..0000000 --- a/backend/src/modules/repo/entities/repo.entity.ts +++ /dev/null @@ -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; - - // 라우팅/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; -} diff --git a/backend/src/modules/repo/repo-sync.service.ts b/backend/src/modules/repo/repo-sync.service.ts deleted file mode 100644 index f77f23a..0000000 --- a/backend/src/modules/repo/repo-sync.service.ts +++ /dev/null @@ -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, - private readonly gitea: GiteaService, - private readonly projectService: ProjectService, - ) {} - - // 서버 기동 직후 1회 동기화 — 부팅을 막지 않도록 비차단(백그라운드)으로 실행 - onApplicationBootstrap(): void { - void this.syncFromGitea('부트스트랩'); - } - - // 10분마다 동기화 - @Cron(CronExpression.EVERY_10_MINUTES) - async handleScheduledSync(): Promise { - await this.syncFromGitea('스케줄(10분)'); - } - - // 조직 저장소 동기화 본체 — 실패해도 예외를 전파하지 않고 로그만 남긴다. - async syncFromGitea(trigger: string): Promise { - 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(); - const bySlug = new Map(); - for (const r of existing) { - if (r.giteaRepoId !== null) byGiteaId.set(r.giteaRepoId, r); - bySlug.set(r.slugName, r); - } - - const seenGiteaIds = new Set(); - // 이번 동기화에서 이미 매칭된 Relay 저장소(uuid) — 중복 매칭 방지 - const matchedRepoIds = new Set(); - 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 }; - } -} diff --git a/backend/src/modules/repo/repo.controller.ts b/backend/src/modules/repo/repo.controller.ts deleted file mode 100644 index 7d75426..0000000 --- a/backend/src/modules/repo/repo.controller.ts +++ /dev/null @@ -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> { - 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 { - 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 { - 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 { - 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 { - await this.repoService.remove(projectId, repoId, user.id); - return null; - } -} diff --git a/backend/src/modules/repo/repo.module.ts b/backend/src/modules/repo/repo.module.ts deleted file mode 100644 index 2493fd0..0000000 --- a/backend/src/modules/repo/repo.module.ts +++ /dev/null @@ -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 {} diff --git a/backend/src/modules/repo/repo.service.ts b/backend/src/modules/repo/repo.service.ts deleted file mode 100644 index c7ce0dd..0000000 --- a/backend/src/modules/repo/repo.service.ts +++ /dev/null @@ -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, - 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> { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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, - }; - } -} diff --git a/frontend/src/components/ProjectTabs.vue b/frontend/src/components/ProjectTabs.vue index 36f99ee..a65d34a 100644 --- a/frontend/src/components/ProjectTabs.vue +++ b/frontend/src/components/ProjectTabs.vue @@ -1,13 +1,12 @@ @@ -76,7 +76,7 @@ const points = [ 지시부터 완료까지,
팀의 업무 흐름을 하나로
- 저장소 단위로 업무를 정리하고, 담당자에게 명확하게 지시하세요. 진행 상황은 한눈에 + 프로젝트 단위로 업무를 정리하고, 담당자에게 명확하게 지시하세요. 진행 상황은 한눈에 추적됩니다.
diff --git a/frontend/src/pages/relay/MyTasksPage.vue b/frontend/src/pages/relay/MyTasksPage.vue index 4b0ae23..cfd5261 100644 --- a/frontend/src/pages/relay/MyTasksPage.vue +++ b/frontend/src/pages/relay/MyTasksPage.vue @@ -111,7 +111,7 @@ function loadMore() { - 모든 저장소 + 모든 프로젝트 diff --git a/frontend/src/pages/relay/ProjectCreatePage.vue b/frontend/src/pages/relay/ProjectCreatePage.vue index bc9d15a..8729aa6 100644 --- a/frontend/src/pages/relay/ProjectCreatePage.vue +++ b/frontend/src/pages/relay/ProjectCreatePage.vue @@ -51,8 +51,7 @@ async function createProject() {

새 프로젝트 만들기

- 프로젝트를 만들고 업무를 작성해 담당자에게 전달하세요. Gitea 저장소는 프로젝트 생성 후 - 연동할 수 있습니다. + 프로젝트를 만들고 업무를 작성해 담당자에게 전달하세요.

= { active="tasks" :task-count="counts.all" :member-count="memberCount" - :repo-count="repo.repoCount" :can-manage="repo.canManage" /> @@ -426,10 +425,6 @@ const STATUS_LABEL: Record = { .crumb { padding: 1.125rem 0 0.75rem; } -.gitea-bar-spacing { - margin-top: 0.75rem; -} - /* 진행률 스트립 (ProjectHeader 의 extra 슬롯에 주입 — 부모 스코프 스타일 적용) */ .prog-strip { display: flex; diff --git a/frontend/src/pages/relay/ProjectListPage.vue b/frontend/src/pages/relay/ProjectListPage.vue index 8d422bb..e95e2e2 100644 --- a/frontend/src/pages/relay/ProjectListPage.vue +++ b/frontend/src/pages/relay/ProjectListPage.vue @@ -36,7 +36,7 @@ const filteredRepos = computed(() => {{ total }}

- 프로젝트를 열어 업무를 작성하고 담당자에게 전달하세요. 저장소는 프로젝트에 연동할 수 있습니다. + 프로젝트를 열어 업무를 작성하고 담당자에게 전달하세요.

@@ -119,32 +119,11 @@ const filteredRepos = computed(() =>
{{ repo.name }} - 시스템
{{ repo.desc }}
- - - - - - 저장소 {{ repo.repoCount }} - letter-spacing: -0.025rem; white-space: nowrap; } -.gitea-badge { - margin-left: auto; - display: inline-flex; - align-items: center; - gap: 0.375rem; - font-size: 0.75rem; - font-weight: 600; - color: #1b7a3d; - background: var(--green-weak); - border: 1px solid var(--green-border); - padding: 0.25rem 0.625rem; - border-radius: 20px; - line-height: 1; - white-space: nowrap; -} -.gitea-badge svg { - width: 0.8125rem; - height: 0.8125rem; -} .lede { color: var(--text-2); font-size: 0.844rem; @@ -383,24 +343,4 @@ const filteredRepos = computed(() => color: var(--text-3); font-size: 0.844rem; } - -/* 원격(Gitea) 없음 배지 */ -.missing-badge { - display: inline-flex; - align-items: center; - gap: 0.25rem; - font-size: 0.6875rem; - font-weight: 600; - color: var(--red); - background: var(--red-weak); - border: 1px solid var(--red-border); - padding: 0.125rem 0.4375rem; - border-radius: 20px; - line-height: 1.4; - white-space: nowrap; -} -.missing-badge svg { - width: 0.75rem; - height: 0.75rem; -} diff --git a/frontend/src/pages/relay/ProjectMembersPage.vue b/frontend/src/pages/relay/ProjectMembersPage.vue index 15e5def..fcb7803 100644 --- a/frontend/src/pages/relay/ProjectMembersPage.vue +++ b/frontend/src/pages/relay/ProjectMembersPage.vue @@ -232,7 +232,6 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown)) active="members" :task-count="repo.totalCount" :member-count="counts.all" - :repo-count="repo.repoCount" :can-manage="repo.canManage" /> diff --git a/frontend/src/pages/relay/ProjectReposPage.vue b/frontend/src/pages/relay/ProjectReposPage.vue deleted file mode 100644 index cadfb2f..0000000 --- a/frontend/src/pages/relay/ProjectReposPage.vue +++ /dev/null @@ -1,723 +0,0 @@ - - -