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:
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
// 프로젝트 서브탭 — 업무/멤버/저장소/활동/설정
|
||||
// 프로젝트 서브탭 — 업무/멤버/활동/설정
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
interface Props {
|
||||
projectId: string
|
||||
active: 'tasks' | 'members' | 'repos' | 'activity' | 'settings'
|
||||
active: 'tasks' | 'members' | 'activity' | 'settings'
|
||||
taskCount: number
|
||||
memberCount: number
|
||||
repoCount: number
|
||||
/** 설정 탭 노출 여부(관리자만). 기본 false — 권한이 확인된 경우에만 노출 */
|
||||
canManage?: boolean
|
||||
}
|
||||
@@ -28,12 +27,6 @@ defineProps<Props>()
|
||||
>
|
||||
<span class="tb-label">멤버</span> <span class="tb-count">{{ memberCount }}</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
:to="`/projects/${projectId}/repos`"
|
||||
:class="{ active: active === 'repos' }"
|
||||
>
|
||||
<span class="tb-label">저장소</span> <span class="tb-count">{{ repoCount }}</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
:to="`/projects/${projectId}/activity`"
|
||||
:class="{ active: active === 'activity' }"
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useApi } from './useApi'
|
||||
import type {
|
||||
ApiRepo,
|
||||
CreateRepoPayload,
|
||||
RepoCreateMeta,
|
||||
} from '@/types/repo'
|
||||
import type { Paginated } from '@/types/pagination'
|
||||
|
||||
// 저장소 도메인 API Composable — 프로젝트에 연동된 Gitea 저장소.
|
||||
// 모든 경로는 /projects/:projectId/repos 하위. 인터셉터가 success/data 를 언래핑.
|
||||
export function useRepo() {
|
||||
const api = useApi()
|
||||
|
||||
async function list(
|
||||
projectId: string,
|
||||
page = 1,
|
||||
size = 20,
|
||||
): Promise<Paginated<ApiRepo>> {
|
||||
return (await api.get(`/projects/${projectId}/repos`, {
|
||||
params: { page, size },
|
||||
})) as unknown as Paginated<ApiRepo>
|
||||
}
|
||||
|
||||
// 생성 폼 메타(소유자/템플릿)
|
||||
async function getCreateMeta(projectId: string): Promise<RepoCreateMeta> {
|
||||
return (await api.get(
|
||||
`/projects/${projectId}/repos/meta`,
|
||||
)) as unknown as RepoCreateMeta
|
||||
}
|
||||
|
||||
// slug 사용 가능 여부 — 전역 유일 검사
|
||||
async function checkName(
|
||||
projectId: string,
|
||||
slug: string,
|
||||
): Promise<boolean> {
|
||||
const res = (await api.get(`/projects/${projectId}/repos/name-available`, {
|
||||
params: { slug },
|
||||
})) as unknown as { available: boolean }
|
||||
return res.available
|
||||
}
|
||||
|
||||
async function create(
|
||||
projectId: string,
|
||||
payload: CreateRepoPayload,
|
||||
): Promise<ApiRepo> {
|
||||
return (await api.post(
|
||||
`/projects/${projectId}/repos`,
|
||||
payload,
|
||||
)) as unknown as ApiRepo
|
||||
}
|
||||
|
||||
// 연동 해제(삭제)
|
||||
async function remove(projectId: string, repoId: string): Promise<void> {
|
||||
await api.delete(`/projects/${projectId}/repos/${repoId}`)
|
||||
}
|
||||
|
||||
return { list, getCreateMeta, checkName, create, remove }
|
||||
}
|
||||
@@ -35,52 +35,16 @@ export interface ChecklistItem {
|
||||
done: boolean
|
||||
}
|
||||
|
||||
/** 저장소 */
|
||||
export interface Repo {
|
||||
/** 라우팅 식별자(slug 의 마지막 segment) */
|
||||
id: string
|
||||
name: string
|
||||
owner: string
|
||||
slug: string
|
||||
desc: string
|
||||
branch: string
|
||||
members: User[]
|
||||
/** 멤버 스택에서 추가로 더 있는 인원 수(+N) */
|
||||
moreCount: number
|
||||
updatedAgo: string
|
||||
doneCount: number
|
||||
totalCount: number
|
||||
progressPct: number
|
||||
/** 진행률 바 색상 단계 */
|
||||
progressLevel: 'done' | 'mid' | 'low'
|
||||
/** 진행률 텍스트(예: "67% 완료", "시작 전") */
|
||||
progressLabel: string
|
||||
/** Gitea HTTPS clone URL (미연동 시 null/undefined) */
|
||||
cloneUrl?: string | null
|
||||
/** Gitea 웹 UI URL (미연동 시 null/undefined) */
|
||||
htmlUrl?: string | null
|
||||
/** Gitea 조직에서 사라진 저장소 표시(역방향 동기화로 갱신) */
|
||||
giteaMissing?: boolean
|
||||
/** 현재 사용자가 이 저장소 관리자(admin)인지 — 단건 조회에서만 채워짐(설정 탭 노출/진입) */
|
||||
canManage?: boolean
|
||||
/** 현재 사용자가 소유자인지 — 저장소 삭제 권한 */
|
||||
isOwner?: boolean
|
||||
}
|
||||
|
||||
/** 프로젝트 — 작업의 기본 단위(우선). 저장소는 프로젝트에 0~N개 연동된다. */
|
||||
/** 프로젝트 — 작업의 기본 단위(우선) */
|
||||
export interface Project {
|
||||
/** 라우팅 식별자(UUID) */
|
||||
id: string
|
||||
name: string
|
||||
desc: string
|
||||
/** "미분류" 시스템 프로젝트 */
|
||||
isSystem: boolean
|
||||
members: User[]
|
||||
/** 멤버 스택 초과분(+N) */
|
||||
moreCount: number
|
||||
updatedAgo: string
|
||||
/** 연동 저장소 수 */
|
||||
repoCount: number
|
||||
doneCount: number
|
||||
totalCount: number
|
||||
progressPct: number
|
||||
|
||||
@@ -55,9 +55,9 @@ function goSocial(provider: 'google' | 'kakao') {
|
||||
|
||||
// 브랜드 패널 강조 포인트
|
||||
const points = [
|
||||
'저장소 · 업무 · 담당자로 이어지는 명확한 구조',
|
||||
'프로젝트 · 업무 · 담당자로 이어지는 명확한 구조',
|
||||
'체크리스트와 마감기한으로 진행 관리',
|
||||
'Gitea 연동으로 안전하게',
|
||||
'역할 기반 권한으로 안전하게 관리',
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -76,7 +76,7 @@ const points = [
|
||||
지시부터 완료까지,<br>팀의 업무 흐름을 하나로
|
||||
</div>
|
||||
<div class="bp-sub">
|
||||
저장소 단위로 업무를 정리하고, 담당자에게 명확하게 지시하세요. 진행 상황은 한눈에
|
||||
프로젝트 단위로 업무를 정리하고, 담당자에게 명확하게 지시하세요. 진행 상황은 한눈에
|
||||
추적됩니다.
|
||||
</div>
|
||||
<div class="bp-points">
|
||||
|
||||
@@ -111,7 +111,7 @@ function loadMore() {
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
모든 저장소
|
||||
모든 프로젝트
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
|
||||
@@ -111,7 +111,6 @@ const ActIcon = defineComponent({
|
||||
active="activity"
|
||||
:task-count="repo.totalCount"
|
||||
:member-count="memberCount"
|
||||
:repo-count="repo.repoCount"
|
||||
:can-manage="repo.canManage"
|
||||
/>
|
||||
|
||||
|
||||
@@ -51,8 +51,7 @@ async function createProject() {
|
||||
<h1>새 프로젝트 만들기</h1>
|
||||
</div>
|
||||
<p class="lede">
|
||||
프로젝트를 만들고 업무를 작성해 담당자에게 전달하세요. Gitea 저장소는 프로젝트 생성 후
|
||||
연동할 수 있습니다.
|
||||
프로젝트를 만들고 업무를 작성해 담당자에게 전달하세요.
|
||||
</p>
|
||||
|
||||
<form
|
||||
|
||||
@@ -157,7 +157,6 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
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<TaskStatus, string> = {
|
||||
.crumb {
|
||||
padding: 1.125rem 0 0.75rem;
|
||||
}
|
||||
.gitea-bar-spacing {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* 진행률 스트립 (ProjectHeader 의 extra 슬롯에 주입 — 부모 스코프 스타일 적용) */
|
||||
.prog-strip {
|
||||
display: flex;
|
||||
|
||||
@@ -36,7 +36,7 @@ const filteredRepos = computed(() =>
|
||||
<span class="count-pill">{{ total }}</span>
|
||||
</div>
|
||||
<p class="lede">
|
||||
프로젝트를 열어 업무를 작성하고 담당자에게 전달하세요. 저장소는 프로젝트에 연동할 수 있습니다.
|
||||
프로젝트를 열어 업무를 작성하고 담당자에게 전달하세요.
|
||||
</p>
|
||||
|
||||
<!-- 툴바 -->
|
||||
@@ -119,32 +119,11 @@ const filteredRepos = computed(() =>
|
||||
<div class="repo-main">
|
||||
<div class="repo-title">
|
||||
<span class="repo-name">{{ repo.name }}</span>
|
||||
<span
|
||||
v-if="repo.isSystem"
|
||||
class="repo-slug"
|
||||
>시스템</span>
|
||||
</div>
|
||||
<div class="repo-desc">
|
||||
{{ repo.desc }}
|
||||
</div>
|
||||
<div class="repo-meta">
|
||||
<span
|
||||
v-if="repo.repoCount > 0"
|
||||
class="mi"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
저장소 {{ repo.repoCount }}
|
||||
</span>
|
||||
<span class="mi avstack">
|
||||
<span
|
||||
v-for="m in repo.members"
|
||||
@@ -231,25 +210,6 @@ const filteredRepos = computed(() =>
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,723 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 프로젝트 연동 저장소 — 연동된 Gitea 저장소 목록 + 저장소 연동(생성) + 연동 해제
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import ProjectHeader from '@/components/ProjectHeader.vue'
|
||||
import ProjectTabs from '@/components/ProjectTabs.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useRepo } from '@/composables/useRepo'
|
||||
import type { Project } from '@/mock/relay.mock'
|
||||
import type { ApiRepo, RepoCreateMeta } from '@/types/repo'
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => String(route.params.projectId))
|
||||
const projectStore = useProjectStore()
|
||||
const repoApi = useRepo()
|
||||
|
||||
// 프로젝트 헤더/탭 — API 연동
|
||||
const project = ref<Project | null>(null)
|
||||
async function loadProject() {
|
||||
try {
|
||||
project.value = await projectStore.fetchOne(projectId.value)
|
||||
} catch {
|
||||
project.value = null // 404/403 은 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
|
||||
const memberCount = computed(() =>
|
||||
project.value ? project.value.members.length + project.value.moreCount : 0,
|
||||
)
|
||||
const canManage = computed(() => project.value?.canManage ?? false)
|
||||
|
||||
// 연동 저장소 목록
|
||||
const repos = ref<ApiRepo[]>([])
|
||||
const loading = ref(false)
|
||||
async function loadRepos() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await repoApi.list(projectId.value)
|
||||
repos.value = res.items
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
projectId,
|
||||
() => {
|
||||
loadProject()
|
||||
loadRepos()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// --- 저장소 연동(생성) 폼 ---
|
||||
const showForm = ref(false)
|
||||
const meta = ref<RepoCreateMeta | null>(null)
|
||||
const slug = ref('')
|
||||
const name = ref('')
|
||||
const description = ref('')
|
||||
const useTemplate = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
// slug 가용성 — null(미확인) / true / false
|
||||
const slugAvailable = ref<boolean | null>(null)
|
||||
const slugChecking = ref(false)
|
||||
let slugTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const slugValid = computed(() => /^[a-z0-9-_]+$/.test(slug.value.trim()))
|
||||
|
||||
// 폼 열기 — 메타(소유자/템플릿) 1회 로드
|
||||
async function openForm() {
|
||||
showForm.value = true
|
||||
if (!meta.value) {
|
||||
try {
|
||||
meta.value = await repoApi.getCreateMeta(projectId.value)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
showForm.value = false
|
||||
slug.value = ''
|
||||
name.value = ''
|
||||
description.value = ''
|
||||
useTemplate.value = false
|
||||
slugAvailable.value = null
|
||||
}
|
||||
|
||||
// slug 입력 디바운스 → 가용성 체크
|
||||
watch(slug, () => {
|
||||
slugAvailable.value = null
|
||||
if (slugTimer) clearTimeout(slugTimer)
|
||||
const value = slug.value.trim()
|
||||
if (!value || !slugValid.value) return
|
||||
slugChecking.value = true
|
||||
slugTimer = setTimeout(async () => {
|
||||
try {
|
||||
slugAvailable.value = await repoApi.checkName(projectId.value, value)
|
||||
} catch {
|
||||
slugAvailable.value = null
|
||||
} finally {
|
||||
slugChecking.value = false
|
||||
}
|
||||
}, 300)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (slugTimer) clearTimeout(slugTimer)
|
||||
})
|
||||
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
!submitting.value &&
|
||||
slugValid.value &&
|
||||
slugAvailable.value === true &&
|
||||
name.value.trim() !== '' &&
|
||||
description.value.trim() !== '',
|
||||
)
|
||||
|
||||
// 저장소 연동 — 성공 시 목록 갱신 + 폼 닫기
|
||||
async function linkRepo() {
|
||||
if (!canSubmit.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await repoApi.create(projectId.value, {
|
||||
slug: slug.value.trim(),
|
||||
name: name.value.trim(),
|
||||
description: description.value.trim(),
|
||||
...(meta.value?.template.available ? { useTemplate: useTemplate.value } : {}),
|
||||
})
|
||||
closeForm()
|
||||
await Promise.all([loadRepos(), loadProject()])
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 연동 해제 — 확인 후 삭제, 목록 갱신
|
||||
async function unlinkRepo(repo: ApiRepo) {
|
||||
const ok = window.confirm(
|
||||
`저장소 ‘${repo.name}’의 연동을 해제합니다.\nGitea 저장소도 함께 삭제됩니다. 계속할까요?`,
|
||||
)
|
||||
if (!ok) return
|
||||
try {
|
||||
await repoApi.remove(projectId.value, repo.id)
|
||||
await Promise.all([loadRepos(), loadProject()])
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div
|
||||
v-if="project"
|
||||
class="page"
|
||||
>
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/projects"
|
||||
class="lnk"
|
||||
>
|
||||
프로젝트
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<RouterLink
|
||||
:to="`/projects/${project.id}`"
|
||||
class="lnk"
|
||||
>
|
||||
{{ project.name }}
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<b>저장소</b>
|
||||
</div>
|
||||
|
||||
<ProjectHeader
|
||||
:repo="project"
|
||||
:meta-text="`연동 저장소 ${project.repoCount}개`"
|
||||
:members="project.members"
|
||||
:more-count="project.moreCount"
|
||||
/>
|
||||
<ProjectTabs
|
||||
:project-id="project.id"
|
||||
active="repos"
|
||||
:task-count="project.totalCount"
|
||||
:member-count="memberCount"
|
||||
:repo-count="project.repoCount"
|
||||
:can-manage="project.canManage"
|
||||
/>
|
||||
|
||||
<!-- 안내 + 연동 버튼 -->
|
||||
<div class="repos-head">
|
||||
<div class="tab-note">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/><path d="M12 16v-4M12 8h.01" />
|
||||
</svg>
|
||||
이 프로젝트에 코드 저장소를 연동하면 업무를 코드 변경과 연결해 관리할 수 있습니다. 한 프로젝트에 여러 저장소를 연동할 수 있습니다.
|
||||
</div>
|
||||
<button
|
||||
v-if="canManage && !showForm"
|
||||
class="btn primary"
|
||||
type="button"
|
||||
@click="openForm"
|
||||
>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 12h14M12 5v14" />
|
||||
</svg>
|
||||
저장소 연동
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 연동(생성) 폼 -->
|
||||
<form
|
||||
v-if="showForm"
|
||||
class="card form"
|
||||
@submit.prevent="linkRepo"
|
||||
>
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
<span class="req">*</span> 표시 제목
|
||||
</div>
|
||||
<div class="fhint">
|
||||
목록과 화면에 표시되는 이름입니다.
|
||||
</div>
|
||||
<input
|
||||
v-model="name"
|
||||
type="text"
|
||||
class="tinput"
|
||||
placeholder="예: 3분기 신제품 런칭 캠페인"
|
||||
maxlength="40"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
<span class="req">*</span> 저장소 이름(slug)
|
||||
</div>
|
||||
<div class="fhint">
|
||||
영문 소문자·숫자와 하이픈(-)·밑줄(_)만 사용. {{ meta?.owner ? `${meta.owner}/` : '' }}이름 형태로 생성됩니다.
|
||||
</div>
|
||||
<input
|
||||
v-model="slug"
|
||||
type="text"
|
||||
class="tinput"
|
||||
placeholder="예: q3-launch-campaign"
|
||||
maxlength="60"
|
||||
autocomplete="off"
|
||||
>
|
||||
<div
|
||||
v-if="slug.trim() && !slugValid"
|
||||
class="fstatus bad"
|
||||
>
|
||||
사용할 수 없는 형식입니다.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="slugChecking"
|
||||
class="fstatus"
|
||||
>
|
||||
확인 중…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="slugAvailable === true"
|
||||
class="fstatus ok"
|
||||
>
|
||||
사용 가능한 이름입니다.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="slugAvailable === false"
|
||||
class="fstatus bad"
|
||||
>
|
||||
이미 사용 중인 이름입니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
<span class="req">*</span> 설명
|
||||
</div>
|
||||
<textarea
|
||||
v-model="description"
|
||||
class="tinput tarea"
|
||||
maxlength="300"
|
||||
placeholder="저장소의 목적을 간단히 적어주세요."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 템플릿 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
템플릿
|
||||
</div>
|
||||
<div class="fhint">
|
||||
템플릿을 사용하면 해당 저장소의 폴더 구조·파일이 새 저장소에 그대로 복제됩니다.
|
||||
(기본 브랜치는 템플릿을 따릅니다)
|
||||
</div>
|
||||
<div class="select-wrap">
|
||||
<select v-model="useTemplate">
|
||||
<option :value="false">
|
||||
없음 (빈 저장소)
|
||||
</option>
|
||||
<option
|
||||
v-if="meta?.template.available"
|
||||
:value="true"
|
||||
>
|
||||
{{ meta.template.slug }} 템플릿
|
||||
</option>
|
||||
</select>
|
||||
<span class="caret">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<button
|
||||
class="btn ghost"
|
||||
type="button"
|
||||
@click="closeForm"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
type="submit"
|
||||
:disabled="!canSubmit"
|
||||
>
|
||||
{{ submitting ? '연동 중…' : '연동' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 연동 저장소 목록 — 연동 추가 폼이 열려 있을 땐 숨김 -->
|
||||
<div
|
||||
v-if="!showForm"
|
||||
class="list"
|
||||
>
|
||||
<div
|
||||
v-for="repo in repos"
|
||||
:key="repo.id"
|
||||
class="repo-row"
|
||||
>
|
||||
<div class="repo-ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="repo-main">
|
||||
<div class="repo-title">
|
||||
<span class="repo-name">{{ repo.name }}</span>
|
||||
<span
|
||||
v-if="repo.giteaMissing"
|
||||
class="missing"
|
||||
>Gitea 연결 끊김</span>
|
||||
</div>
|
||||
<div class="repo-sub">
|
||||
<span class="slug">{{ repo.slug }}</span>
|
||||
<span class="dot">·</span>
|
||||
<span class="branch">{{ repo.branch }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repo-actions">
|
||||
<a
|
||||
v-if="repo.htmlUrl"
|
||||
:href="repo.htmlUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn ghost sm"
|
||||
>
|
||||
Gitea 열기
|
||||
</a>
|
||||
<button
|
||||
v-if="canManage"
|
||||
class="btn danger sm"
|
||||
type="button"
|
||||
@click="unlinkRepo(repo)"
|
||||
>
|
||||
연동 해제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading && repos.length === 0"
|
||||
class="list-empty"
|
||||
>
|
||||
불러오는 중…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="repos.length === 0"
|
||||
class="list-empty"
|
||||
>
|
||||
아직 연동된 저장소가 없습니다.{{ canManage ? ' ‘저장소 연동’으로 추가해 보세요.' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트를 찾지 못한 경우 -->
|
||||
<div
|
||||
v-else
|
||||
class="page"
|
||||
>
|
||||
<p class="not-found-msg">
|
||||
요청하신 프로젝트를 찾을 수 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.crumb {
|
||||
padding: 1.125rem 0 0.75rem;
|
||||
}
|
||||
.repos-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin: 1.125rem 0 1rem;
|
||||
}
|
||||
/* 멤버 탭 가이드(.tab-note)와 동일한 형식 */
|
||||
.tab-note {
|
||||
flex: 1;
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
}
|
||||
.tab-note svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
color: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn {
|
||||
height: 2.25rem;
|
||||
padding: 0 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.844rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn.sm {
|
||||
height: 1.875rem;
|
||||
padding: 0 0.75rem;
|
||||
font-size: 0.781rem;
|
||||
}
|
||||
.btn.primary {
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.btn.primary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn.ghost {
|
||||
border: 1px solid var(--border-strong);
|
||||
background: #fff;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.btn.danger {
|
||||
border: 1px solid var(--red-border);
|
||||
background: #fff;
|
||||
color: var(--red);
|
||||
}
|
||||
.btn.danger:hover {
|
||||
background: var(--red-weak);
|
||||
}
|
||||
|
||||
/* 연동 폼 */
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
.form {
|
||||
margin-bottom: 1.125rem;
|
||||
}
|
||||
.fblock {
|
||||
padding: 1.125rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.fblock:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.flabel {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.4375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.flabel .req {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.fhint {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
margin-bottom: 0.5625rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.tinput {
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.75rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
}
|
||||
.tinput:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
textarea.tarea {
|
||||
min-height: 4.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
height: auto;
|
||||
}
|
||||
.fstatus {
|
||||
font-size: 0.781rem;
|
||||
margin-top: 0.4375rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.fstatus.ok {
|
||||
color: #1b7a3d;
|
||||
}
|
||||
.fstatus.bad {
|
||||
color: var(--red);
|
||||
}
|
||||
/* 템플릿 select */
|
||||
.select-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.select-wrap select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 2.375rem 0 0.75rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.select-wrap select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.select-wrap .caret {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-3);
|
||||
pointer-events: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.select-wrap .caret svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.form-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5625rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: #fafbfc;
|
||||
border-radius: 0 0 0.625rem 0.625rem;
|
||||
}
|
||||
|
||||
/* 저장소 목록 */
|
||||
.list {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
.repo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.repo-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.repo-ico {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5625rem;
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.repo-ico svg {
|
||||
width: 1.1875rem;
|
||||
height: 1.1875rem;
|
||||
}
|
||||
.repo-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.repo-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.repo-name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.missing {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--red);
|
||||
background: var(--red-weak);
|
||||
border: 1px solid var(--red-border);
|
||||
border-radius: 20px;
|
||||
padding: 0.125rem 0.5rem;
|
||||
}
|
||||
.repo-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
margin-top: 0.3125rem;
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.repo-sub .dot {
|
||||
font-family: inherit;
|
||||
}
|
||||
.repo-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.list-empty {
|
||||
padding: 2.5rem 1.25rem;
|
||||
text-align: center;
|
||||
color: var(--text-3);
|
||||
font-size: 0.844rem;
|
||||
}
|
||||
.not-found-msg {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
color: var(--text-2);
|
||||
}
|
||||
</style>
|
||||
@@ -112,7 +112,6 @@ async function removeRepo() {
|
||||
active="settings"
|
||||
:task-count="repo.totalCount"
|
||||
:member-count="memberCount"
|
||||
:repo-count="repo.repoCount"
|
||||
:can-manage="repo.canManage"
|
||||
/>
|
||||
|
||||
@@ -320,10 +319,6 @@ textarea.tarea {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.gitea-bar-spacing {
|
||||
margin-top: 0.6875rem;
|
||||
}
|
||||
|
||||
.readonly {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -63,12 +63,6 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/relay/ProjectMembersPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/projects/:projectId/repos',
|
||||
name: 'project-repos',
|
||||
component: () => import('@/pages/relay/ProjectReposPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/projects/:projectId/activity',
|
||||
name: 'project-activity',
|
||||
|
||||
@@ -45,10 +45,6 @@ function toRepoActivityItem(api: ApiActivity): RepoActivityItem {
|
||||
icon: 'pencil',
|
||||
html: `<b>${actor}</b>님이 표시 제목을 ‘${escapeHtml(pstr(p, 'newName'))}’(으)로 변경했습니다`,
|
||||
}
|
||||
case 'repo.linked':
|
||||
return { ...base, tone: 'setting', icon: 'repo', html: `<b>${actor}</b>님이 저장소 <b>${escapeHtml(pstr(p, 'repoName'))}</b>을(를) 연동했습니다` }
|
||||
case 'repo.unlinked':
|
||||
return { ...base, tone: 'setting', icon: 'repo', html: `<b>${actor}</b>님이 저장소 <b>${escapeHtml(pstr(p, 'repoName'))}</b> 연동을 해제했습니다` }
|
||||
case 'member.invited':
|
||||
return { ...base, tone: 'member', icon: 'member-add', html: `<b>${actor}</b>님이 <b>${target}</b>님을 멤버로 초대했습니다` }
|
||||
case 'member.role_changed':
|
||||
|
||||
@@ -31,11 +31,9 @@ function toProjectView(api: ApiProject): ProjectView {
|
||||
id: api.id,
|
||||
name: api.name,
|
||||
desc: api.description ?? '',
|
||||
isSystem: api.isSystem,
|
||||
members: shown,
|
||||
moreCount: Math.max(0, api.memberCount - shown.length),
|
||||
updatedAgo: `${relativeTimeKo(api.updatedAt)} 업데이트`,
|
||||
repoCount: api.repoCount,
|
||||
doneCount: api.doneCount,
|
||||
totalCount: api.totalCount,
|
||||
progressPct: prog.pct,
|
||||
|
||||
@@ -6,8 +6,6 @@ import type { ApiMember } from '@/types/repo'
|
||||
export type ApiActivityType =
|
||||
| 'project.created'
|
||||
| 'project.renamed'
|
||||
| 'repo.linked'
|
||||
| 'repo.unlinked'
|
||||
| 'member.invited'
|
||||
| 'member.role_changed'
|
||||
| 'member.removed'
|
||||
|
||||
@@ -7,10 +7,8 @@ export interface ApiProject {
|
||||
id: string // UUID (라우팅 식별자)
|
||||
name: string
|
||||
description: string | null
|
||||
isSystem: boolean // "미분류" 시스템 프로젝트
|
||||
members: ApiMember[]
|
||||
memberCount: number
|
||||
repoCount: number // 연동 저장소 수
|
||||
doneCount: number
|
||||
totalCount: number
|
||||
updatedAt: string
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 저장소 멤버(API 원시) — PublicUser 와 동일
|
||||
// 공개 사용자(API 원시) — 백엔드 PublicUser 와 동일. 멤버/담당자/작성자 등에 공통 사용.
|
||||
export interface ApiMember {
|
||||
id: string
|
||||
email: string
|
||||
@@ -6,43 +6,3 @@ export interface ApiMember {
|
||||
role: string | null
|
||||
avatarUrl: string | null
|
||||
}
|
||||
|
||||
// 저장소(API 원시) — 백엔드 RepoResponse 와 1:1. 프로젝트에 연동된 Gitea 저장소(코드 연동 전용).
|
||||
export interface ApiRepo {
|
||||
id: string // slugName (라우팅 식별자)
|
||||
projectId: string // 소속 프로젝트
|
||||
name: string
|
||||
owner: string
|
||||
slug: string // owner/slugName
|
||||
desc: string | null
|
||||
branch: string
|
||||
cloneUrl: string | null // Gitea HTTPS clone URL (미연동 시 null)
|
||||
htmlUrl: string | null // Gitea 웹 UI URL (미연동 시 null)
|
||||
giteaMissing: boolean // Gitea 조직에서 사라진 저장소 표시(동기화로 갱신)
|
||||
updatedAt: string
|
||||
// 현재 사용자 권한 — 단건 조회에서만 내려옴(목록은 미포함)
|
||||
canManage?: boolean // 프로젝트 admin (설정 변경)
|
||||
isOwner?: boolean // 프로젝트 소유자
|
||||
}
|
||||
|
||||
// 저장소 생성 요청
|
||||
export interface CreateRepoPayload {
|
||||
slug: string // 영문 저장소 이름
|
||||
name: string // 표시 제목(한글)
|
||||
description: string // 프로젝트 설명(필수)
|
||||
branch?: string
|
||||
useTemplate?: boolean // 템플릿 저장소 복제 여부
|
||||
}
|
||||
|
||||
// 저장소 생성 폼 메타데이터 (소유자/템플릿)
|
||||
export interface RepoCreateMeta {
|
||||
owner: string
|
||||
template: { available: boolean; slug: string }
|
||||
}
|
||||
|
||||
// 저장소 수정 요청
|
||||
export interface UpdateRepoPayload {
|
||||
name?: string
|
||||
description?: string
|
||||
branch?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user