Compare commits
5 Commits
e1bc5a1dfc
...
a8069aa72b
| Author | SHA1 | Date | |
|---|---|---|---|
| a8069aa72b | |||
| 7f432a0013 | |||
| 6652e79768 | |||
| efe7b29581 | |||
| 6027856cbb |
@@ -12,6 +12,19 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
|
||||
JWT_ACCESS_TTL=15m
|
||||
JWT_REFRESH_TTL=14d
|
||||
|
||||
# --- Gitea 연동 (외부 Gitea 인스턴스) ---
|
||||
# 세 값이 모두 채워져야 연동 활성화. 비우면 저장소는 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
|
||||
|
||||
# --- 단독 실행(non-Docker) 시 주석 해제 ---
|
||||
# NODE_ENV=development
|
||||
# DB_HOST=localhost
|
||||
|
||||
@@ -12,6 +12,19 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
|
||||
JWT_ACCESS_TTL=15m
|
||||
JWT_REFRESH_TTL=14d
|
||||
|
||||
# --- Gitea 연동 (외부 Gitea 인스턴스) ---
|
||||
# 세 값이 모두 채워져야 연동 활성화. 비우면 저장소는 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
|
||||
|
||||
# --- 단독 실행(non-Docker) 시 주석 해제 ---
|
||||
# NODE_ENV=development
|
||||
# DB_HOST=localhost
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GiteaService } from './gitea.service';
|
||||
|
||||
// Gitea 연동 모듈 — 외부 Gitea 인스턴스의 조직 저장소를 생성/수정/삭제하는 클라이언트 제공
|
||||
@Module({
|
||||
providers: [GiteaService],
|
||||
exports: [GiteaService],
|
||||
})
|
||||
export class GiteaModule {}
|
||||
@@ -0,0 +1,121 @@
|
||||
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,
|
||||
full_name: 'marketing-team/q3-launch',
|
||||
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,
|
||||
fullName: 'marketing-team/q3-launch',
|
||||
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('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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
// Gitea 저장소 생성/조회 결과(우리가 보관하는 핵심 필드만 추림)
|
||||
export interface GiteaRepoResult {
|
||||
id: number;
|
||||
fullName: string; // 'org/slug'
|
||||
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;
|
||||
full_name: string;
|
||||
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) 는 변경하지 않음
|
||||
async updateRepo(
|
||||
name: string,
|
||||
patch: { description?: string; private?: boolean },
|
||||
): Promise<void> {
|
||||
await this.request<GiteaRepoApi>(
|
||||
'PATCH',
|
||||
`/repos/${encodeURIComponent(this.org)}/${encodeURIComponent(name)}`,
|
||||
{
|
||||
description: patch.description,
|
||||
private: patch.private,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 저장소 삭제
|
||||
async deleteRepo(name: string): Promise<void> {
|
||||
await this.request<null>(
|
||||
'DELETE',
|
||||
`/repos/${encodeURIComponent(this.org)}/${encodeURIComponent(name)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- 내부 헬퍼 ---
|
||||
|
||||
// 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,
|
||||
fullName: data.full_name,
|
||||
cloneUrl: data.clone_url,
|
||||
htmlUrl: data.html_url,
|
||||
defaultBranch: data.default_branch,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
@@ -41,13 +42,27 @@ export class CreateRepoDto {
|
||||
@IsIn(['private', 'public'], { message: '공개 범위를 선택해 주세요.' })
|
||||
visibility!: RepoVisibility;
|
||||
|
||||
@ApiProperty({ description: '프로젝트 설명(선택)', required: false })
|
||||
@IsOptional()
|
||||
@ApiProperty({
|
||||
description: '프로젝트 설명(필수)',
|
||||
example: '3분기 신제품 런칭을 위한 마케팅 캠페인 저장소',
|
||||
})
|
||||
@IsString()
|
||||
description?: string;
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { MemberRoleType } from '../entities/repo-member.entity';
|
||||
|
||||
// 멤버 초대 DTO — 가입된 사용자를 이메일로 초대(별도 초대 메일/계정 생성 없음)
|
||||
export class InviteMemberDto {
|
||||
@ApiProperty({
|
||||
description: '초대할 사용자 이메일(이미 가입된 사용자)',
|
||||
example: 'sykim@acme.co',
|
||||
})
|
||||
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
|
||||
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '권한 역할',
|
||||
enum: ['admin', 'member'],
|
||||
example: 'member',
|
||||
})
|
||||
@IsIn(['admin', 'member'], { message: '권한 역할을 선택해 주세요.' })
|
||||
roleType!: MemberRoleType;
|
||||
|
||||
@ApiProperty({
|
||||
description: '저장소 내 직무(선택)',
|
||||
example: '콘텐츠 디자인',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
subRole?: string;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { MemberRoleType } from '../entities/repo-member.entity';
|
||||
|
||||
// 멤버 수정 DTO — 권한 역할/저장소 내 직무 (owner 는 변경 불가)
|
||||
export class UpdateMemberDto {
|
||||
@ApiProperty({
|
||||
description: '권한 역할',
|
||||
enum: ['admin', 'member'],
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['admin', 'member'], { message: '권한 역할을 선택해 주세요.' })
|
||||
roleType?: MemberRoleType;
|
||||
|
||||
@ApiProperty({ description: '저장소 내 직무', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
subRole?: string;
|
||||
}
|
||||
@@ -43,6 +43,24 @@ export class Repo {
|
||||
@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;
|
||||
|
||||
// 멤버 목록
|
||||
@OneToMany(() => RepoMember, (member) => member.repo)
|
||||
members!: Relation<RepoMember>[];
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
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 { MemberService, type RepoMemberResponse } from './member.service';
|
||||
import { InviteMemberDto } from './dto/invite-member.dto';
|
||||
import { UpdateMemberDto } from './dto/update-member.dto';
|
||||
|
||||
// 저장소 멤버 컨트롤러 — 모든 엔드포인트 인증 필요 (repoId = slugName)
|
||||
@ApiTags('Member')
|
||||
@Controller('repos/:repoId/members')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MemberController {
|
||||
constructor(private readonly memberService: MemberService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '저장소 멤버 목록 (멤버 또는 공개)' })
|
||||
@ApiResponse({ status: 200, description: '목록 조회 성공' })
|
||||
@ApiResponse({ status: 403, description: '접근 권한 없음 (AUTH_003)' })
|
||||
list(
|
||||
@Param('repoId') repoId: string,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<RepoMemberResponse[]> {
|
||||
return this.memberService.list(repoId, user.id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '멤버 초대 (admin)' })
|
||||
@ApiResponse({ status: 201, description: '초대 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: '가입된 사용자를 찾을 수 없음 (BIZ_001)',
|
||||
})
|
||||
@ApiResponse({ status: 409, description: '이미 멤버 (BIZ_001)' })
|
||||
invite(
|
||||
@Param('repoId') repoId: string,
|
||||
@Body() dto: InviteMemberDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<RepoMemberResponse> {
|
||||
return this.memberService.invite(repoId, user.id, dto);
|
||||
}
|
||||
|
||||
@Patch(':userId')
|
||||
@ApiOperation({ summary: '멤버 역할/직무 변경 (admin, owner 제외)' })
|
||||
@ApiResponse({ status: 200, description: '변경 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 / 소유자 변경 불가' })
|
||||
update(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: UpdateMemberDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<RepoMemberResponse> {
|
||||
return this.memberService.update(repoId, user.id, userId, dto);
|
||||
}
|
||||
|
||||
@Delete(':userId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '멤버 제거 (admin, owner 제외)' })
|
||||
@ApiResponse({ status: 200, description: '제거 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 / 소유자 제거 불가' })
|
||||
async remove(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('userId') userId: string,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<null> {
|
||||
await this.memberService.remove(repoId, user.id, userId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { MemberService } from './member.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { Repo } from './entities/repo.entity';
|
||||
import { RepoMember } from './entities/repo-member.entity';
|
||||
|
||||
// 저장소/멤버 리포지토리 + UserService 간이 모킹
|
||||
function makeService() {
|
||||
const repoRepo = { findOne: jest.fn() };
|
||||
const memberRepo = {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
};
|
||||
const userService = { findByEmail: jest.fn() };
|
||||
const svc = new MemberService(
|
||||
repoRepo as unknown as Repository<Repo>,
|
||||
memberRepo as unknown as Repository<RepoMember>,
|
||||
userService as unknown as UserService,
|
||||
);
|
||||
return { svc, repoRepo, memberRepo, userService };
|
||||
}
|
||||
|
||||
const REPO = { id: 'repo-uuid', slugName: 'q3-launch', visibility: 'private' };
|
||||
const ADMIN = { roleType: 'admin' };
|
||||
|
||||
describe('MemberService', () => {
|
||||
describe('list', () => {
|
||||
it('멤버가 아니고 비공개면 접근 거부', async () => {
|
||||
const { svc, repoRepo, memberRepo } = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
// 목록에 현재 사용자(other-user)가 없음
|
||||
memberRepo.find.mockResolvedValue([
|
||||
{ user: { id: 'someone-else', email: 'a@b.c', name: 'A', role: null } },
|
||||
]);
|
||||
|
||||
await expect(svc.list('q3-launch', 'other-user')).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invite', () => {
|
||||
it('가입되지 않은 이메일이면 BusinessException', async () => {
|
||||
const { svc, repoRepo, memberRepo, userService } = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
memberRepo.findOne.mockResolvedValueOnce(ADMIN); // assertAdmin 통과
|
||||
userService.findByEmail.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
svc.invite('q3-launch', 'admin-user', {
|
||||
email: 'ghost@acme.co',
|
||||
roleType: 'member',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BusinessException);
|
||||
});
|
||||
|
||||
it('이미 멤버면 BusinessException', async () => {
|
||||
const { svc, repoRepo, memberRepo, userService } = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
memberRepo.findOne
|
||||
.mockResolvedValueOnce(ADMIN) // assertAdmin
|
||||
.mockResolvedValueOnce({ id: 'existing-member' }); // 기존 멤버십 존재
|
||||
userService.findByEmail.mockResolvedValue({ id: 'u1', email: 'u@b.c' });
|
||||
|
||||
await expect(
|
||||
svc.invite('q3-launch', 'admin-user', {
|
||||
email: 'u@b.c',
|
||||
roleType: 'member',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BusinessException);
|
||||
});
|
||||
|
||||
it('admin 이 아니면 ForbiddenException', async () => {
|
||||
const { svc, repoRepo, memberRepo } = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
memberRepo.findOne.mockResolvedValueOnce({ roleType: 'member' }); // 일반 멤버
|
||||
|
||||
await expect(
|
||||
svc.invite('q3-launch', 'member-user', {
|
||||
email: 'u@b.c',
|
||||
roleType: 'member',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update / remove — 소유자 보호', () => {
|
||||
it('소유자의 역할은 변경할 수 없다', async () => {
|
||||
const { svc, repoRepo, memberRepo } = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
memberRepo.findOne
|
||||
.mockResolvedValueOnce(ADMIN) // assertAdmin
|
||||
.mockResolvedValueOnce({ isOwner: true, user: { id: 'owner' } }); // 대상=소유자
|
||||
|
||||
await expect(
|
||||
svc.update('q3-launch', 'admin-user', 'owner', { roleType: 'member' }),
|
||||
).rejects.toBeInstanceOf(BusinessException);
|
||||
});
|
||||
|
||||
it('소유자는 제거할 수 없다', async () => {
|
||||
const { svc, repoRepo, memberRepo } = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
memberRepo.findOne
|
||||
.mockResolvedValueOnce(ADMIN) // assertAdmin
|
||||
.mockResolvedValueOnce({ isOwner: true, user: { id: 'owner' } }); // 대상=소유자
|
||||
|
||||
await expect(
|
||||
svc.remove('q3-launch', 'admin-user', 'owner'),
|
||||
).rejects.toBeInstanceOf(BusinessException);
|
||||
expect(memberRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { Repo } from './entities/repo.entity';
|
||||
import { RepoMember, type MemberRoleType } from './entities/repo-member.entity';
|
||||
import { InviteMemberDto } from './dto/invite-member.dto';
|
||||
import { UpdateMemberDto } from './dto/update-member.dto';
|
||||
|
||||
// 멤버 응답 형태 — isYou 는 프론트가 현재 사용자와 비교해 산출
|
||||
export interface RepoMemberResponse {
|
||||
user: PublicUser;
|
||||
email: string;
|
||||
subRole: string | null;
|
||||
taskCount: number; // TODO(4단계): 실제 담당 업무 수 집계
|
||||
roleType: MemberRoleType;
|
||||
owner: boolean; // 소유자 여부(isOwner)
|
||||
}
|
||||
|
||||
// 저장소 멤버 관리 비즈니스 로직
|
||||
@Injectable()
|
||||
export class MemberService {
|
||||
constructor(
|
||||
@InjectRepository(Repo)
|
||||
private readonly repoRepo: Repository<Repo>,
|
||||
@InjectRepository(RepoMember)
|
||||
private readonly memberRepo: Repository<RepoMember>,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
// 멤버 목록 — 멤버이거나 공개 저장소만 조회 가능
|
||||
async list(slugName: string, userId: string): Promise<RepoMemberResponse[]> {
|
||||
const repo = await this.getRepoOrThrow(slugName);
|
||||
const members = await this.memberRepo.find({
|
||||
where: { repo: { id: repo.id } },
|
||||
relations: ['user'],
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
const isMember = members.some((m) => m.user.id === userId);
|
||||
if (!isMember && repo.visibility !== 'public') {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
return members.map((m) => this.toResponse(m));
|
||||
}
|
||||
|
||||
// 멤버 초대 — admin 권한. 이미 가입된 사용자만 이메일로 초대
|
||||
async invite(
|
||||
slugName: string,
|
||||
actingUserId: string,
|
||||
dto: InviteMemberDto,
|
||||
): Promise<RepoMemberResponse> {
|
||||
const repo = await this.getRepoOrThrow(slugName);
|
||||
await this.assertAdmin(repo.id, actingUserId);
|
||||
|
||||
const user = await this.userService.findByEmail(dto.email);
|
||||
if (!user) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'해당 이메일의 가입된 사용자를 찾을 수 없습니다. 먼저 가입이 필요합니다.',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.memberRepo.findOne({
|
||||
where: { repo: { id: repo.id }, user: { id: user.id } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'이미 저장소에 속한 멤버입니다.',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
const member = this.memberRepo.create({
|
||||
repo,
|
||||
user,
|
||||
roleType: dto.roleType,
|
||||
isOwner: false,
|
||||
subRole: dto.subRole?.trim() || null,
|
||||
});
|
||||
const saved = await this.memberRepo.save(member);
|
||||
saved.user = user; // 관계 보장(응답 매핑용)
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
// 멤버 역할/직무 변경 — admin 권한. 소유자(owner)는 변경 불가
|
||||
async update(
|
||||
slugName: string,
|
||||
actingUserId: string,
|
||||
targetUserId: string,
|
||||
dto: UpdateMemberDto,
|
||||
): Promise<RepoMemberResponse> {
|
||||
const repo = await this.getRepoOrThrow(slugName);
|
||||
await this.assertAdmin(repo.id, actingUserId);
|
||||
const member = await this.getMemberOrThrow(repo.id, targetUserId);
|
||||
if (member.isOwner) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'소유자의 권한은 변경할 수 없습니다.',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
if (dto.roleType !== undefined) member.roleType = dto.roleType;
|
||||
if (dto.subRole !== undefined) member.subRole = dto.subRole.trim() || null;
|
||||
|
||||
await this.memberRepo.save(member);
|
||||
return this.toResponse(member);
|
||||
}
|
||||
|
||||
// 멤버 제거 — admin 권한. 소유자(owner)는 제거 불가
|
||||
async remove(
|
||||
slugName: string,
|
||||
actingUserId: string,
|
||||
targetUserId: string,
|
||||
): Promise<void> {
|
||||
const repo = await this.getRepoOrThrow(slugName);
|
||||
await this.assertAdmin(repo.id, actingUserId);
|
||||
const member = await this.getMemberOrThrow(repo.id, targetUserId);
|
||||
if (member.isOwner) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'소유자는 저장소에서 제거할 수 없습니다.',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
await this.memberRepo.remove(member);
|
||||
}
|
||||
|
||||
// --- 내부 헬퍼 ---
|
||||
|
||||
// slugName 으로 저장소 로딩, 없으면 404
|
||||
private async getRepoOrThrow(slugName: string): Promise<Repo> {
|
||||
const repo = await this.repoRepo.findOne({ where: { slugName } });
|
||||
if (!repo) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return repo;
|
||||
}
|
||||
|
||||
// 저장소-사용자 멤버십 로딩(+user), 없으면 404
|
||||
private async getMemberOrThrow(
|
||||
repoId: string,
|
||||
userId: string,
|
||||
): Promise<RepoMember> {
|
||||
const member = await this.memberRepo.findOne({
|
||||
where: { repo: { id: repoId }, user: { id: userId } },
|
||||
relations: ['user'],
|
||||
});
|
||||
if (!member) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return member;
|
||||
}
|
||||
|
||||
// admin 권한 확인 (아니면 403)
|
||||
private async assertAdmin(repoId: string, userId: string): Promise<void> {
|
||||
const membership = await this.memberRepo.findOne({
|
||||
where: { repo: { id: repoId }, user: { id: userId } },
|
||||
});
|
||||
if (!membership || membership.roleType !== 'admin') {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
// 엔티티 → 응답 매핑
|
||||
private toResponse(member: RepoMember): RepoMemberResponse {
|
||||
return {
|
||||
user: UserService.toPublic(member.user),
|
||||
email: member.user.email,
|
||||
subRole: member.subRole,
|
||||
taskCount: 0,
|
||||
roleType: member.roleType,
|
||||
owner: member.isOwner,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,17 @@ export class RepoController {
|
||||
return this.repoService.create(dto, user.id);
|
||||
}
|
||||
|
||||
// ':repoId' 보다 먼저 선언해야 'meta' 가 파라미터로 매칭되지 않음
|
||||
@Get('meta')
|
||||
@ApiOperation({ summary: '저장소 생성 폼 메타(소유자/템플릿) 조회' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
getCreateMeta(): {
|
||||
owner: string;
|
||||
template: { available: boolean; slug: string };
|
||||
} {
|
||||
return this.repoService.getCreateMeta();
|
||||
}
|
||||
|
||||
@Get(':repoId')
|
||||
@ApiOperation({ summary: '저장소 단건 조회' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { GiteaModule } from '../gitea/gitea.module';
|
||||
import { Repo } from './entities/repo.entity';
|
||||
import { RepoMember } from './entities/repo-member.entity';
|
||||
import { RepoService } from './repo.service';
|
||||
import { RepoController } from './repo.controller';
|
||||
import { MemberService } from './member.service';
|
||||
import { MemberController } from './member.controller';
|
||||
|
||||
// 저장소 모듈
|
||||
// 저장소 모듈 (저장소 + 멤버 관리)
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Repo, RepoMember]), UserModule],
|
||||
controllers: [RepoController],
|
||||
providers: [RepoService],
|
||||
exports: [RepoService],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Repo, RepoMember]),
|
||||
UserModule,
|
||||
GiteaModule,
|
||||
],
|
||||
controllers: [RepoController, MemberController],
|
||||
providers: [RepoService, MemberService],
|
||||
exports: [RepoService, MemberService],
|
||||
})
|
||||
export class RepoModule {}
|
||||
|
||||
@@ -2,20 +2,29 @@ import {
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import {
|
||||
GiteaApiError,
|
||||
GiteaService,
|
||||
type GiteaRepoResult,
|
||||
} from '../gitea/gitea.service';
|
||||
import { Repo, type RepoVisibility } from './entities/repo.entity';
|
||||
import { RepoMember } from './entities/repo-member.entity';
|
||||
import { CreateRepoDto } from './dto/create-repo.dto';
|
||||
import { UpdateRepoDto } from './dto/update-repo.dto';
|
||||
|
||||
// 현재는 단일 조직 고정 (조직 모델 도입 시 교체)
|
||||
// Gitea 연동 비활성 시 사용할 기본 소유자 (조직 모델 도입 시 교체)
|
||||
const DEFAULT_OWNER = 'marketing-team';
|
||||
|
||||
// Gitea 저장소 이름 충돌 시 반환되는 HTTP 상태 (enum 비교 회피용 숫자 상수)
|
||||
const HTTP_CONFLICT = 409;
|
||||
|
||||
// 저장소 응답 형태 — 파생 라벨/진행률은 프론트에서 계산하므로 원시값 위주로 내린다
|
||||
export interface RepoResponse {
|
||||
id: string; // slugName (라우팅 식별자)
|
||||
@@ -29,20 +38,41 @@ export interface RepoResponse {
|
||||
memberCount: number;
|
||||
doneCount: number;
|
||||
totalCount: number;
|
||||
cloneUrl: string | null; // Gitea HTTPS clone URL (미연동 시 null)
|
||||
htmlUrl: string | null; // Gitea 웹 UI URL (미연동 시 null)
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 저장소 비즈니스 로직
|
||||
@Injectable()
|
||||
export class RepoService {
|
||||
private readonly logger = new Logger(RepoService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Repo)
|
||||
private readonly repoRepo: Repository<Repo>,
|
||||
@InjectRepository(RepoMember)
|
||||
private readonly memberRepo: Repository<RepoMember>,
|
||||
private readonly userService: UserService,
|
||||
private readonly gitea: GiteaService,
|
||||
) {}
|
||||
|
||||
// 저장소 생성 폼 메타데이터 — 소유자(조직) 고정 표시 + 템플릿 가용 정보.
|
||||
// 저장소 생성 전 프론트가 미리 조회하여 표시한다.
|
||||
getCreateMeta(): {
|
||||
owner: string;
|
||||
template: { available: boolean; slug: string };
|
||||
} {
|
||||
return {
|
||||
// 연동 시 owner = GITEA_ORG, 아니면 기본 상수
|
||||
owner: this.gitea.enabled ? this.gitea.org : DEFAULT_OWNER,
|
||||
template: {
|
||||
available: this.gitea.templateEnabled,
|
||||
slug: this.gitea.templateSlug,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 내가 멤버인 저장소 목록
|
||||
async findAllForUser(userId: string): Promise<RepoResponse[]> {
|
||||
const memberships = await this.memberRepo.find({
|
||||
@@ -73,7 +103,7 @@ export class RepoService {
|
||||
return this.toResponse(repo);
|
||||
}
|
||||
|
||||
// 저장소 생성 — 생성자를 소유자(admin)로 등록
|
||||
// 저장소 생성 — Gitea 조직에 실제 repo 를 만들고, 생성자를 소유자(admin)로 등록
|
||||
async create(dto: CreateRepoDto, userId: string): Promise<RepoResponse> {
|
||||
const existing = await this.repoRepo.findOne({
|
||||
where: { slugName: dto.slug },
|
||||
@@ -90,26 +120,66 @@ export class RepoService {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
const repo = this.repoRepo.create({
|
||||
slugName: dto.slug,
|
||||
owner: DEFAULT_OWNER,
|
||||
name: dto.name,
|
||||
description: dto.description ?? null,
|
||||
visibility: dto.visibility,
|
||||
branch: dto.branch?.trim() || 'main',
|
||||
});
|
||||
const saved = await this.repoRepo.save(repo);
|
||||
const description = dto.description;
|
||||
const visibility = dto.visibility;
|
||||
const requestedBranch = dto.branch?.trim() || 'main';
|
||||
// 연동 시 소유자는 Gitea 조직명 — slug `owner/slugName` 이 Gitea full_name 과 일치
|
||||
const owner = this.gitea.enabled ? this.gitea.org : DEFAULT_OWNER;
|
||||
|
||||
const owner = this.memberRepo.create({
|
||||
repo: saved,
|
||||
user,
|
||||
roleType: 'admin',
|
||||
isOwner: true,
|
||||
subRole: '리드',
|
||||
});
|
||||
await this.memberRepo.save(owner);
|
||||
// 1) Gitea 조직에 실제 저장소 생성 (연동 활성 시)
|
||||
// - useTemplate: 템플릿 저장소를 복제해 생성(브랜치는 템플릿을 따름)
|
||||
// - 아니면 빈 저장소(auto_init, 요청 브랜치)
|
||||
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,
|
||||
});
|
||||
}
|
||||
// 템플릿 복제 시 실제 기본 브랜치는 Gitea 응답값을 우선 사용
|
||||
const branch = giteaRepo?.defaultBranch || requestedBranch;
|
||||
|
||||
return this.toResponse(await this.getEntityOrThrow(saved.slugName));
|
||||
// 2) Relay 메타데이터 저장 (Gitea 연동 정보 포함)
|
||||
try {
|
||||
const repo = this.repoRepo.create({
|
||||
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,
|
||||
});
|
||||
const saved = await this.repoRepo.save(repo);
|
||||
|
||||
const ownerMember = this.memberRepo.create({
|
||||
repo: saved,
|
||||
user,
|
||||
roleType: 'admin',
|
||||
isOwner: true,
|
||||
subRole: '리드',
|
||||
});
|
||||
await this.memberRepo.save(ownerMember);
|
||||
|
||||
return this.toResponse(await this.getEntityOrThrow(saved.slugName));
|
||||
} 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 권한 필요
|
||||
@@ -121,6 +191,10 @@ export class RepoService {
|
||||
const repo = await this.getEntityOrThrow(slugName);
|
||||
await this.assertAdmin(repo.id, userId);
|
||||
|
||||
// 표시제목/설명은 Gitea description 으로, 공개범위는 private 플래그로 동기화
|
||||
const descChanged = dto.name !== undefined || dto.description !== undefined;
|
||||
const visChanged = dto.visibility !== undefined;
|
||||
|
||||
if (dto.name !== undefined) repo.name = dto.name;
|
||||
if (dto.description !== undefined) repo.description = dto.description;
|
||||
if (dto.visibility !== undefined) repo.visibility = dto.visibility;
|
||||
@@ -128,6 +202,28 @@ export class RepoService {
|
||||
repo.branch = dto.branch.trim();
|
||||
|
||||
await this.repoRepo.save(repo);
|
||||
|
||||
// Gitea 메타데이터 동기화 (베스트 에포트 — 실패해도 Relay 수정은 유지)
|
||||
if (
|
||||
this.gitea.enabled &&
|
||||
repo.giteaRepoId !== null &&
|
||||
(descChanged || visChanged)
|
||||
) {
|
||||
await this.gitea
|
||||
.updateRepo(repo.slugName, {
|
||||
description: descChanged
|
||||
? this.buildGiteaDescription(repo.name, repo.description)
|
||||
: undefined,
|
||||
private: visChanged ? repo.visibility === 'private' : undefined,
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
this.logger.error(
|
||||
`Gitea 저장소 수정 동기화 실패: ${repo.slugName}`,
|
||||
e instanceof Error ? e.stack : String(e),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return this.toResponse(await this.getEntityOrThrow(slugName));
|
||||
}
|
||||
|
||||
@@ -141,10 +237,77 @@ export class RepoService {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.repoRepo.remove(repo);
|
||||
|
||||
// Gitea 저장소도 삭제 (베스트 에포트 — 실패 시 로그만 남기고 Relay 삭제는 유지)
|
||||
if (this.gitea.enabled && repo.giteaRepoId !== null) {
|
||||
await this.gitea.deleteRepo(repo.slugName).catch((e: unknown) => {
|
||||
this.logger.error(
|
||||
`Gitea 저장소 삭제 동기화 실패: ${repo.slugName}`,
|
||||
e instanceof Error ? e.stack : String(e),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- 내부 헬퍼 ---
|
||||
|
||||
// Gitea 조직에 저장소 생성 — 실패 시 사용자 노출 가능한 BusinessException 으로 변환.
|
||||
// useTemplate 이면 템플릿 저장소를 복제(generate), 아니면 빈 저장소(auto_init).
|
||||
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) {
|
||||
// Gitea 측 이름 충돌 → Relay 와 동일한 중복 문구로 안내
|
||||
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 저장소 설명 문자열 구성 — Gitea 에 별도 표시명 필드가 없어 '표시제목 — 설명' 으로 합친다
|
||||
private buildGiteaDescription(
|
||||
name: string,
|
||||
description: string | null,
|
||||
): string {
|
||||
return description ? `${name} — ${description}` : name;
|
||||
}
|
||||
|
||||
// slugName 으로 저장소(+멤버) 로딩, 없으면 404
|
||||
private async getEntityOrThrow(slugName: string): Promise<Repo> {
|
||||
const repo = await this.repoRepo.findOne({
|
||||
@@ -185,6 +348,8 @@ export class RepoService {
|
||||
// TODO(4단계): 업무 모듈 도입 시 실제 완료/전체 수 집계
|
||||
doneCount: 0,
|
||||
totalCount: 0,
|
||||
cloneUrl: repo.cloneUrl,
|
||||
htmlUrl: repo.htmlUrl,
|
||||
updatedAt: repo.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
+28
-3
@@ -80,20 +80,25 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
|
||||
| 메서드 | 경로 | 요청 | 응답 |
|
||||
|---|---|---|---|
|
||||
| GET | `/repos` | `?page&size` | `Repo[]`(요약) — 내가 멤버인 저장소 |
|
||||
| GET | `/repos/meta` | — | `{ owner, template }` — 생성 폼 메타(`:repoId` 보다 먼저 매칭) |
|
||||
| POST | `/repos` | `CreateRepoDto` | `Repo` |
|
||||
| GET | `/repos/:repoId` | — | `Repo`(상세) |
|
||||
| PATCH | `/repos/:repoId` | `UpdateRepoDto` | `Repo` |
|
||||
| DELETE | `/repos/:repoId` | — | `null` |
|
||||
|
||||
`CreateRepoDto`: `{ name, desc, visibility('private'|'public'), branch? }`
|
||||
`GET /repos/meta` 응답: `{ owner: string, template: { available: boolean, slug: string } }`
|
||||
— 생성 전 소유자(조직) 고정 표시 + 템플릿 옵션 노출용.
|
||||
|
||||
`CreateRepoDto`: `{ slug, name, visibility('private'|'public'), description, branch?, useTemplate? }`
|
||||
(**description 필수**, `useTemplate=true` 면 템플릿 저장소를 복제해 생성)
|
||||
`Repo` 응답(원시값 중심):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "q3-launch-campaign", // slug 마지막 segment = 라우팅 id
|
||||
"name": "3분기 신제품 런칭 캠페인",
|
||||
"owner": "marketing-team",
|
||||
"slug": "marketing-team/q3-launch-campaign",
|
||||
"owner": "marketing-team", // Gitea 연동 시 Gitea 조직명
|
||||
"slug": "marketing-team/q3-launch-campaign", // = Gitea full_name
|
||||
"desc": "...",
|
||||
"visibility": "private",
|
||||
"branch": "main",
|
||||
@@ -101,12 +106,32 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
|
||||
"memberCount": 5,
|
||||
"doneCount": 8,
|
||||
"totalCount": 12,
|
||||
"cloneUrl": "https://gitea.example.com/marketing-team/q3-launch-campaign.git", // 미연동 시 null
|
||||
"htmlUrl": "https://gitea.example.com/marketing-team/q3-launch-campaign", // 미연동 시 null
|
||||
"updatedAt": "2026-06-16T11:00:00Z"
|
||||
}
|
||||
```
|
||||
> `moreCount`, `progressPct/Level/Label`, `updatedAgo` 는 프론트 계산.
|
||||
> 매핑 페이지: RepoListPage, RepoCreatePage, RepoDetailPage(헤더), RepoSettingsPage.
|
||||
|
||||
#### Gitea 연동 (외부 인스턴스)
|
||||
|
||||
저장소 생명주기를 외부 Gitea 조직에 동기화한다. `GITEA_BASE_URL`/`GITEA_TOKEN`/`GITEA_ORG`
|
||||
(backend `.env`) 가 모두 설정되면 활성화되고, 비어 있으면 Gitea 호출 없이 기존대로 동작한다.
|
||||
|
||||
| Relay 동작 | Gitea API | 매핑 |
|
||||
|---|---|---|
|
||||
| `POST /repos` (빈 저장소) | `POST /api/v1/orgs/{org}/repos` (auto_init) | `slug`→repo name, `visibility`→private, `branch`→default_branch, `name`+`desc`→description |
|
||||
| `POST /repos` (`useTemplate`) | `POST /api/v1/repos/{tplOwner}/{tplRepo}/generate` (git_content) | `owner`→org, `slug`→name, `name`+`desc`→description, `visibility`→private. 기본 브랜치는 템플릿을 따름 |
|
||||
| `PATCH /repos/:id` | `PATCH /api/v1/repos/{org}/{slug}` | `name`/`desc` 변경→description, `visibility` 변경→private (slug 불변) |
|
||||
| `DELETE /repos/:id` | `DELETE /api/v1/repos/{org}/{slug}` | — |
|
||||
|
||||
- 템플릿 출처: `GITEA_TEMPLATE_OWNER`/`GITEA_TEMPLATE_REPO`(기본 `TtiPo`/`project-base`). 해당 저장소는 Gitea 에서 *template* 로 설정돼 있어야 한다.
|
||||
- 인증: 조직 권한 토큰 단일 사용(`Authorization: token <TOKEN>`). 사용자별 Gitea 계정 매핑은 추후.
|
||||
- 생성은 Gitea 우선 → 성공 시 Relay 저장(연동 정보 `giteaRepoId`/`cloneUrl`/`htmlUrl` 보관). Relay 저장 실패 시 Gitea repo 롤백 삭제.
|
||||
- Gitea 이름 충돌(409) → `BIZ_001` "이미 사용 중인 저장소 이름입니다.", 그 외 실패 → `BIZ_001`(502).
|
||||
- 수정/삭제의 Gitea 동기화는 **베스트 에포트**(실패해도 Relay 상태는 반영, 에러 로그만 남김).
|
||||
|
||||
---
|
||||
|
||||
## 3. 멤버 (`/api/repos/:repoId/members`) — **3단계**
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
> 프론트 UI(목업)를 NestJS 백엔드에 단계적으로 연동하는 작업의 진행 기록.
|
||||
> 계약/스키마 상세는 [`api-contract.md`](./api-contract.md) 참조.
|
||||
> 최종 갱신: 2026-06-16 · 현재 위치: **2단계(저장소) 완료, 3단계(멤버) 대기**
|
||||
> 최종 갱신: 2026-06-17 · 현재 위치: **0~3단계 완료(+ Gitea 연동·템플릿 복제·생성폼 보강), 4단계(업무) 대기**
|
||||
> 남은 프론트 연동: RepoMembersPage(3단계), cloneUrl/htmlUrl 표시(2.5단계). 그 외 4~8단계 미착수.
|
||||
|
||||
---
|
||||
|
||||
@@ -16,8 +17,8 @@
|
||||
### 단계 순서
|
||||
|
||||
```
|
||||
0. 계약 정렬 ✅ → 1. 인증 ✅ → 2. 저장소 ✅
|
||||
→ 3. 멤버 → 4. 업무 → 5. 업무 상세 부속 → 6. 활동 피드 → 7. 내 업무 → 8. 마무리
|
||||
0. 계약 정렬 ✅ → 1. 인증 ✅ → 2. 저장소 ✅ (+ Gitea 연동 ✅) → 3. 멤버 ✅(백엔드)
|
||||
→ 4. 업무 → 5. 업무 상세 부속 → 6. 활동 피드 → 7. 내 업무 → 8. 마무리
|
||||
```
|
||||
|
||||
---
|
||||
@@ -63,15 +64,52 @@
|
||||
- `types/repo.ts`, `composables/useRepo.ts`, `stores/repo.store.ts`(API→기존 mock `Repo`/`User` 뷰로 매핑: 아바타 3명+`moreCount`, 진행률, 상대시간).
|
||||
- 페이지 연동: **RepoListPage**(목록·로딩/빈상태), **RepoCreatePage**(생성→상세), **RepoDetailPage**(헤더·진행률만), **RepoSettingsPage**(저장·삭제·이름변경 안내).
|
||||
|
||||
### 2.5단계 — Gitea 조직 연동 ✅
|
||||
|
||||
**백엔드** `backend/src/modules/gitea/`
|
||||
- `GiteaService`: 외부 Gitea(`/api/v1`) 얇은 클라이언트(Node 내장 `fetch`, 새 의존성 없음). `createRepo`/`updateRepo`/`deleteRepo` + `enabled` 게이트. `GiteaApiError`(상태코드 보존).
|
||||
- 환경변수 `GITEA_BASE_URL`/`GITEA_TOKEN`/`GITEA_ORG` 셋 다 있어야 활성화. 비어 있으면 저장소 CRUD 가 Gitea 호출 없이 기존대로 동작(로컬/미연동 대비).
|
||||
- `Repo` 엔티티에 연동 컬럼 추가: `giteaRepoId`, `giteaFullName`, `cloneUrl`, `htmlUrl`(전부 nullable). `RepoResponse` 에 `cloneUrl`/`htmlUrl` 노출.
|
||||
- `RepoService` 와이어링:
|
||||
- **생성**: Gitea 조직에 repo 생성 → 성공 시 Relay 저장(연동정보 보관). 소유자=`GITEA_ORG`. 이름 충돌(409)→`BIZ_001`. Relay 저장 실패 시 Gitea repo 롤백 삭제. (빈 저장소=`auto_init`, 템플릿=`generate` — 아래 생성폼 보강 참조)
|
||||
- **수정**: 표시제목/설명→Gitea description, 공개범위→private 동기화(slug 불변). 베스트 에포트.
|
||||
- **삭제**: Gitea repo 삭제 동기화. 베스트 에포트(실패해도 Relay 삭제 유지, 에러 로그).
|
||||
- 인프라 변경 없음(외부 Gitea). 토큰은 JWT 처럼 `backend/.env.{env}` 의 백엔드 고유 시크릿. 커밋용 `backend/.env.{env}.example` 에 키 문서화.
|
||||
|
||||
> **TODO(프론트)**: RepoSettingsPage/RepoDetailPage 에 `cloneUrl`/`htmlUrl` 표시(클론 주소 복사, Gitea 열기 버튼). 백엔드 응답에는 이미 포함.
|
||||
|
||||
### 3단계 — 멤버 관리 ✅ (백엔드)
|
||||
|
||||
**백엔드** `backend/src/modules/repo/`
|
||||
- `MemberService` + `MemberController`(repo 모듈에 흡수, `RepoMember` 엔티티 재사용). 엔드포인트: `GET/POST/PATCH/DELETE /repos/:repoId/members[/:userId]`.
|
||||
- **목록**: 멤버이거나 공개 저장소만 조회(생성순 정렬). 응답 `RepoMemberResponse { user, email, subRole, taskCount, roleType, owner }`.
|
||||
- **초대**: admin 권한. **이미 가입된 사용자만 이메일로 초대**(별도 초대 메일/계정 생성 없음). 미가입 이메일→`BIZ_001`(404), 이미 멤버→`BIZ_001`(409).
|
||||
- **역할/직무 변경·제거**: admin 권한. **소유자(owner)는 역할 변경·제거 불가**(`BIZ_001` 403).
|
||||
- `InviteMemberDto`(email/roleType/subRole?), `UpdateMemberDto`(roleType?/subRole?). 단위 테스트 `member.service.spec.ts`(소유자 보호·중복·권한 가드).
|
||||
- `taskCount` 는 4단계 업무 모듈 도입 전까지 0.
|
||||
- **Gitea 미동기화**: 인증 모델이 조직 토큰 단일 사용이라 멤버↔Gitea collaborator 동기화는 하지 않음(사용자별 Gitea 계정 매핑은 8단계 후보).
|
||||
|
||||
> **TODO(프론트)**: `useMember`/`member.store`, **RepoMembersPage**(초대 모달·역할 변경·제거) 연동.
|
||||
|
||||
### 저장소 생성 폼 보강 ✅ (소유자/설명/템플릿 — 백엔드+프론트)
|
||||
|
||||
**소유자 하드코딩 제거**
|
||||
- 백엔드 `GET /repos/meta` → `{ owner, template:{available,slug} }`(컨트롤러에서 `:repoId` 보다 먼저 선언). owner=`GITEA_ORG`(미연동 시 상수).
|
||||
- 프론트 `useRepo.meta()` + `repo.store`(`owner`/`templateAvailable`/`templateSlug`/`fetchCreateMeta`). **RepoCreatePage** 고정표시·**RepoListPage** 배너의 `marketing-team` 하드코딩 → 실제 조직값.
|
||||
|
||||
**프로젝트 설명 필수화**
|
||||
- `CreateRepoDto.description` 필수(`@IsNotEmpty`, ≤300자). 프론트 생성폼 라벨 `*`·검증 추가. 누락 시 `VAL_001`(400).
|
||||
|
||||
**템플릿 복제 생성**
|
||||
- `GiteaService.generateFromTemplate()` — Gitea generate API(`POST /repos/{tplOwner}/{tplRepo}/generate`, `git_content:true`)로 템플릿 저장소를 복제. 기본 브랜치는 템플릿을 따름.
|
||||
- 템플릿 출처 env `GITEA_TEMPLATE_OWNER`/`GITEA_TEMPLATE_REPO`(기본 `TtiPo/project-base`, Gitea 에서 *template* 설정 필요).
|
||||
- `CreateRepoDto.useTemplate=true` 면 빈 저장소 대신 복제 생성. 프론트 생성폼 템플릿 드롭다운=실제 옵션(없음 / `TtiPo/project-base`).
|
||||
- 라이브 검증: 생성된 repo 의 파일 트리가 `project-base` 와 동일하게 복제됨 확인.
|
||||
|
||||
---
|
||||
|
||||
## 3. 남은 단계
|
||||
|
||||
### 3단계 — 멤버 관리
|
||||
- 백엔드: `GET/POST/PATCH/DELETE /repos/:repoId/members`(목록·초대·역할변경·제거), admin 권한 가드, owner 제거 불가.
|
||||
- 프론트: `useMember`/`member.store`, **RepoMembersPage**(초대 모달·역할 변경·제거) 연동.
|
||||
- `RepoMember` 엔티티는 2단계에서 이미 존재 → 데이터 계층은 일부 재사용.
|
||||
|
||||
### 4단계 — 업무(Task) CRUD + 상태 전이
|
||||
- 백엔드: `Task`(+`ChecklistItem`) 엔티티, `GET/POST/PATCH/DELETE /repos/:repoId/tasks[/:taskId]`, `POST .../status`. 상태 머신 todo→prog→review→done/changes.
|
||||
- 저장소 `doneCount/totalCount` 실제 집계로 교체(현재 0 하드코딩).
|
||||
@@ -96,7 +134,7 @@
|
||||
|
||||
## 4. 전환기 한계 (현재 시점)
|
||||
|
||||
- **RepoMembersPage·RepoActivityPage** 는 아직 mock `findRepo` 사용 → 실제 생성한 저장소의 **멤버/활동 탭은 "찾을 수 없음"** 표시(각각 3·6단계에서 해소).
|
||||
- **RepoMembersPage** 는 백엔드 API(3단계)는 준비됐으나 **프론트 연동 대기** → 연동 전까지 mock 사용. **RepoActivityPage** 는 아직 mock `findRepo` 사용(6단계에서 해소).
|
||||
- **RepoDetail 업무 목록**도 mock(4단계). 업무 모듈이 없어 모든 저장소 **진행률은 `0% / 시작 전`**.
|
||||
- `src/mock/relay.mock.ts` 는 아직 타입 정의(`Repo`/`User`/`TaskStatus` 등) + 미연동 화면 데이터 공급원으로 사용 중. 해당 도메인이 연동되면 데이터는 제거하고 타입만 `src/types/` 로 이전.
|
||||
|
||||
@@ -107,8 +145,11 @@
|
||||
- **엔티티 순환참조**: 서로 참조하는 관계(예: `Repo`↔`RepoMember`)의 **단일 클래스 관계 속성에는 TypeORM `Relation<>` 래퍼**를 사용한다. 안 그러면 SWC 환경에서 `Cannot access 'X' before initialization` 크래시. → 4·5단계(Task↔Repo, Comment↔Task 등)에도 동일 적용.
|
||||
- **bcrypt → bcryptjs**: Docker 베이스가 `node:20-alpine`(빌드 도구 없음)이라 네이티브 모듈 `bcrypt` 대신 순수 JS `bcryptjs` 사용.
|
||||
- **dev 도커 익명 볼륨**: `/app/node_modules` 가 익명 볼륨이라 **의존성 변경 시 `up -d --build -V`(익명 볼륨 갱신)** 필요. `--build` 만으로는 기존 node_modules 볼륨이 재사용되어 새 패키지가 반영되지 않음.
|
||||
- **owner 고정**: 조직 모델이 없어 `owner='marketing-team'` 상수. 조직 도입 시 교체.
|
||||
- **owner 고정**: 조직 모델이 없어 `owner='marketing-team'` 상수. **Gitea 연동 시 owner=`GITEA_ORG`** 로 대체(미연동 시 상수 폴백). 조직 도입 시 교체.
|
||||
- **ID 전략**: User/Repo PK=UUID, Repo 라우팅 식별자=`slugName`, Task=저장소 내 순번(#9, 4단계).
|
||||
- **Gitea 연동 방식**: HTTP 클라이언트는 새 의존성(@nestjs/axios) 없이 Node 20 내장 `fetch` 사용(베이스 이미지 alpine 호환). 인증은 조직 토큰 1개(사용자별 Gitea 계정 매핑은 8단계 후보). `slug`↔Gitea repo name 1:1, 한글 표시제목은 Gitea description 에 합쳐 저장(Gitea 에 표시명 필드 없음).
|
||||
- **연동 비활성 폴백**: `GITEA_*` 미설정 시 `GiteaService.enabled=false` → 저장소 CRUD 가 Gitea 없이 동작. 로컬 개발/테스트에서 Gitea 없이도 기존 흐름 유지.
|
||||
- **저장소 생성 폼 보강**: ① **프로젝트 설명 필수화**(`CreateRepoDto.description` required, 프론트 검증 추가). ② **템플릿 복제** — `useTemplate=true` 면 Gitea generate API 로 `GITEA_TEMPLATE_OWNER/REPO`(기본 `TtiPo/project-base`, *template* 설정 필요) 저장소를 복제 생성(브랜치는 템플릿을 따름). 프론트는 `GET /repos/meta`(owner+template) 로 owner 고정표시·템플릿 옵션을 받아 하드코딩 제거.
|
||||
|
||||
---
|
||||
|
||||
@@ -124,5 +165,14 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d
|
||||
|
||||
- 백엔드: `npm run build` / `npm run lint` (현재 0 error)
|
||||
- 프론트: `npm run type-check` / `npm run lint` / `npm run build-only` (현재 0 error)
|
||||
- dev `synchronize=true` 가 `users`·`repos`·`repo_members` 테이블 자동 생성. DB가 비어 있으므로 **회원가입 후 로그인**.
|
||||
- dev `synchronize=true` 가 `users`·`repos`·`repo_members` 테이블 자동 생성(Gitea 연동 컬럼 포함). DB가 비어 있으므로 **회원가입 후 로그인**.
|
||||
- 포트: 프론트 5273, 백엔드 3100(`/api`), Swagger `http://localhost:3100/api-docs`.
|
||||
|
||||
### Gitea 연동 설정/검증
|
||||
|
||||
1. `backend/.env.{env}` 에 `GITEA_BASE_URL`/`GITEA_TOKEN`/`GITEA_ORG` 채우기(예시: `backend/.env.{env}.example`).
|
||||
- 토큰: Gitea → Settings → Applications → Generate Token(조직 repo 생성 권한 필요).
|
||||
- docker-compose 는 `backend/.env.{env}` 를 `env_file` 로 자동 주입하므로 compose 수정 불필요.
|
||||
2. 부팅 로그에서 `Gitea 연동 활성화 — <url> (org: <org>)` 확인(미설정 시 비활성 경고).
|
||||
3. Relay 에서 저장소 생성 → Gitea `<org>` 조직에 동일 slug repo 가 생기는지 확인. 응답 `cloneUrl`/`htmlUrl` 채워짐.
|
||||
4. 수정(설명/공개범위)·삭제 시 Gitea 측 반영 확인.
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useApi } from './useApi'
|
||||
import type { ApiRepo, CreateRepoPayload, UpdateRepoPayload } from '@/types/repo'
|
||||
import type {
|
||||
ApiRepo,
|
||||
CreateRepoPayload,
|
||||
RepoCreateMeta,
|
||||
UpdateRepoPayload,
|
||||
} from '@/types/repo'
|
||||
|
||||
// 저장소 도메인 API Composable — 인터셉터가 success/data 를 언래핑
|
||||
export function useRepo() {
|
||||
@@ -9,6 +14,11 @@ export function useRepo() {
|
||||
return (await api.get('/repos')) as unknown as ApiRepo[]
|
||||
}
|
||||
|
||||
// 저장소 생성 폼 메타(소유자/템플릿) — 생성 전 미리 조회
|
||||
async function meta(): Promise<RepoCreateMeta> {
|
||||
return (await api.get('/repos/meta')) as unknown as RepoCreateMeta
|
||||
}
|
||||
|
||||
async function get(id: string): Promise<ApiRepo> {
|
||||
return (await api.get(`/repos/${id}`)) as unknown as ApiRepo
|
||||
}
|
||||
@@ -25,5 +35,5 @@ export function useRepo() {
|
||||
await api.delete(`/repos/${id}`)
|
||||
}
|
||||
|
||||
return { list, get, create, update, remove }
|
||||
return { list, meta, get, create, update, remove }
|
||||
}
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
// 새 저장소 만들기 — 저장소 생성 폼
|
||||
import { ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import { initialOf } from '@/shared/utils/format'
|
||||
import type { Visibility } from '@/mock/relay.mock'
|
||||
|
||||
const router = useRouter()
|
||||
const repoStore = useRepoStore()
|
||||
|
||||
// 생성 폼 메타 — 소유자(GITEA_ORG) 고정 표시 + 템플릿 가용 정보
|
||||
const { owner, templateAvailable, templateSlug } = storeToRefs(repoStore)
|
||||
const ownerInitial = computed(() => initialOf(owner.value || '?').toUpperCase())
|
||||
onMounted(() => {
|
||||
void repoStore.fetchCreateMeta()
|
||||
})
|
||||
|
||||
// 폼 상태
|
||||
const repoName = ref('')
|
||||
const displayTitle = ref('')
|
||||
const visibility = ref<Visibility>('private')
|
||||
const description = ref('')
|
||||
const template = ref('없음 (빈 저장소)')
|
||||
// 템플릿 사용 여부 — 사용 시 설정된 템플릿 저장소(project-base)를 복제해 생성
|
||||
const useTemplate = ref(false)
|
||||
const branch = ref('main')
|
||||
const submitting = ref(false)
|
||||
|
||||
const templates = ['없음 (빈 저장소)', '기본 업무 템플릿', '디자인 프로젝트 템플릿', '개발 프로젝트 템플릿']
|
||||
|
||||
// 생성 — 성공 시 새 저장소 상세로 이동
|
||||
async function createRepo() {
|
||||
if (submitting.value) return
|
||||
@@ -37,6 +45,10 @@ async function createRepo() {
|
||||
window.alert('표시 제목을 입력해 주세요.')
|
||||
return
|
||||
}
|
||||
if (!description.value.trim()) {
|
||||
window.alert('프로젝트 설명을 입력해 주세요.')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
@@ -44,8 +56,9 @@ async function createRepo() {
|
||||
slug: repoName.value.trim(),
|
||||
name: displayTitle.value.trim(),
|
||||
visibility: visibility.value,
|
||||
description: description.value.trim() || undefined,
|
||||
description: description.value.trim(),
|
||||
branch: branch.value.trim() || undefined,
|
||||
useTemplate: useTemplate.value,
|
||||
})
|
||||
await router.push(`/repos/${repo.id}`)
|
||||
} catch {
|
||||
@@ -107,8 +120,8 @@ async function createRepo() {
|
||||
class="owner-fixed"
|
||||
title="소유자는 변경할 수 없습니다"
|
||||
>
|
||||
<span class="owner-avatar">M</span>
|
||||
marketing-team
|
||||
<span class="owner-avatar">{{ ownerInitial }}</span>
|
||||
{{ owner || '조직 불러오는 중…' }}
|
||||
<span class="lock">
|
||||
<svg
|
||||
width="13"
|
||||
@@ -242,15 +255,16 @@ async function createRepo() {
|
||||
<!-- 프로젝트 설명 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
프로젝트 설명
|
||||
<span class="req">*</span> 프로젝트 설명
|
||||
</div>
|
||||
<div class="fhint">
|
||||
저장소 목록과 상단에 표시됩니다. (선택)
|
||||
저장소 목록과 상단에 표시됩니다. 프로젝트의 목적과 범위를 간단히 적어주세요.
|
||||
</div>
|
||||
<textarea
|
||||
v-model="description"
|
||||
class="tinput tarea"
|
||||
placeholder="이 프로젝트의 목적과 범위를 간단히 적어주세요."
|
||||
maxlength="300"
|
||||
placeholder="예: 3분기 신제품 런칭을 위한 콘텐츠·광고 제작 저장소"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -260,15 +274,19 @@ async function createRepo() {
|
||||
템플릿
|
||||
</div>
|
||||
<div class="fhint">
|
||||
선택한 템플릿의 폴더 구조와 기본 업무가 새 저장소에 복제됩니다.
|
||||
템플릿을 사용하면 해당 저장소의 폴더 구조·파일이 새 저장소에 그대로 복제됩니다.
|
||||
(기본 브랜치는 템플릿을 따릅니다)
|
||||
</div>
|
||||
<div class="select-wrap">
|
||||
<select v-model="template">
|
||||
<select v-model="useTemplate">
|
||||
<option :value="false">
|
||||
없음 (빈 저장소)
|
||||
</option>
|
||||
<option
|
||||
v-for="t in templates"
|
||||
:key="t"
|
||||
v-if="templateAvailable"
|
||||
:value="true"
|
||||
>
|
||||
{{ t }}
|
||||
{{ templateSlug }} 템플릿
|
||||
</option>
|
||||
</select>
|
||||
<span class="caret">
|
||||
|
||||
@@ -13,10 +13,11 @@ const keyword = ref('')
|
||||
const filter = ref<Filter>('all')
|
||||
|
||||
const repoStore = useRepoStore()
|
||||
const { repos, loading } = storeToRefs(repoStore)
|
||||
const { repos, loading, owner } = storeToRefs(repoStore)
|
||||
|
||||
onMounted(() => {
|
||||
void repoStore.fetchList()
|
||||
void repoStore.fetchCreateMeta()
|
||||
})
|
||||
|
||||
// 검색어 + 공개여부 필터 적용
|
||||
@@ -59,7 +60,7 @@ const filteredRepos = computed(() =>
|
||||
</span>
|
||||
</div>
|
||||
<p class="lede">
|
||||
<b>marketing-team</b> 조직의 저장소입니다. 저장소를 열어 업무를 작성하고 담당자에게
|
||||
<b>{{ owner || '내' }}</b> 조직의 저장소입니다. 저장소를 열어 업무를 작성하고 담당자에게
|
||||
전달하세요.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -47,9 +47,22 @@ export const useRepoStore = defineStore('repo', () => {
|
||||
const repos = ref<RepoView[]>([])
|
||||
const current = ref<RepoView | null>(null)
|
||||
const loading = ref(false)
|
||||
// 생성 폼 메타 — 소유자(GITEA_ORG)·템플릿 정보. 최초 1회 조회 후 캐시
|
||||
const owner = ref('')
|
||||
const templateAvailable = ref(false)
|
||||
const templateSlug = ref('')
|
||||
|
||||
const repoApi = useRepo()
|
||||
|
||||
// 생성 폼 메타(소유자/템플릿) 조회 — 이미 있으면 재사용
|
||||
async function fetchCreateMeta(): Promise<void> {
|
||||
if (owner.value) return
|
||||
const meta = await repoApi.meta()
|
||||
owner.value = meta.owner
|
||||
templateAvailable.value = meta.template.available
|
||||
templateSlug.value = meta.template.slug
|
||||
}
|
||||
|
||||
// 목록 조회
|
||||
async function fetchList(): Promise<void> {
|
||||
loading.value = true
|
||||
@@ -91,5 +104,18 @@ export const useRepoStore = defineStore('repo', () => {
|
||||
if (current.value?.id === id) current.value = null
|
||||
}
|
||||
|
||||
return { repos, current, loading, fetchList, fetchOne, create, update, remove }
|
||||
return {
|
||||
repos,
|
||||
current,
|
||||
loading,
|
||||
owner,
|
||||
templateAvailable,
|
||||
templateSlug,
|
||||
fetchCreateMeta,
|
||||
fetchList,
|
||||
fetchOne,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -29,8 +29,15 @@ export interface CreateRepoPayload {
|
||||
slug: string // 영문 저장소 이름
|
||||
name: string // 표시 제목(한글)
|
||||
visibility: Visibility
|
||||
description?: string
|
||||
description: string // 프로젝트 설명(필수)
|
||||
branch?: string
|
||||
useTemplate?: boolean // 템플릿 저장소 복제 여부
|
||||
}
|
||||
|
||||
// 저장소 생성 폼 메타데이터 (소유자/템플릿)
|
||||
export interface RepoCreateMeta {
|
||||
owner: string
|
||||
template: { available: boolean; slug: string }
|
||||
}
|
||||
|
||||
// 저장소 수정 요청
|
||||
|
||||
Reference in New Issue
Block a user