feat: Gitea 조직 저장소 역방향 동기화 (기동 시·매시간 import)

- GiteaService.listOrgRepos(): 조직 저장소 페이지네이션 조회
- RepoSyncService: OnApplicationBootstrap(비차단) + @Cron(매시간) 동기화
  - 추가형 import(멤버 0명), 연동정보 backfill, Gitea 누락 시 giteaMissing 표기
- Repo.giteaMissing 컬럼 + RepoResponse/프론트 ApiRepo 노출
- GET /repos 를 '조직 저장소 전체'(findAll)로, 단건은 인증 사용자 열람 허용
- docs(api-contract·integration-progress) 2.6단계 반영

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 13:16:20 +09:00
parent 2b38e21188
commit ae385355b8
10 changed files with 314 additions and 34 deletions
@@ -48,7 +48,10 @@ describe('GiteaService', () => {
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',
@@ -77,7 +80,10 @@ describe('GiteaService', () => {
});
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',
@@ -103,6 +109,57 @@ describe('GiteaService', () => {
});
});
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, '');
@@ -4,7 +4,10 @@ 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;
@@ -24,7 +27,10 @@ export class GiteaApiError extends Error {
// 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;
@@ -146,6 +152,23 @@ export class GiteaService {
);
}
// 조직 아래 모든 저장소 조회(페이지네이션 순회) — 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
@@ -183,7 +206,10 @@ export class GiteaService {
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,
@@ -61,6 +61,11 @@ export class Repo {
@Column({ name: 'html_url', type: 'varchar', nullable: true })
htmlUrl!: string | null;
// Gitea 조직에서 더 이상 발견되지 않는 저장소 표시(동기화로 갱신).
// 연동 origin(giteaRepoId 보유)이 아닌 저장소는 항상 false 유지.
@Column({ name: 'gitea_missing', default: false })
giteaMissing!: boolean;
// 멤버 목록
@OneToMany(() => RepoMember, (member) => member.repo)
members!: Relation<RepoMember>[];
@@ -0,0 +1,180 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { GiteaService } from '../gitea/gitea.service';
import { Repo } from './entities/repo.entity';
// Gitea description 에서 분리한 표시명/설명
interface ParsedDescription {
name: string;
desc: string | null;
}
// Gitea 조직 저장소를 Relay 로 가져오는 동기화 서비스.
// - 서버 기동 직후 1회 + 매시간 정각에 조직의 저장소 목록을 받아 Relay 와 맞춘다.
// - 추가형(additive): Gitea 에만 있는 저장소는 Relay 로 import, 양쪽에 있으면 연동정보 backfill.
// - Gitea 에서 사라진(연동 origin) 저장소는 삭제하지 않고 giteaMissing 플래그로 표기만 한다.
// - 멤버십은 Relay 자체 관리(Gitea collaborator 미동기화) — import 저장소는 멤버 0명으로 들어온다.
@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,
) {}
// 서버 기동 직후 1회 동기화 — 부팅을 막지 않도록 비차단(백그라운드)으로 실행
onApplicationBootstrap(): void {
void this.syncFromGitea('부트스트랩');
}
// 매시간 정각 동기화
@Cron(CronExpression.EVERY_HOUR)
async handleHourlySync(): Promise<void> {
await this.syncFromGitea('스케줄(매시간)');
}
// 조직 저장소 동기화 본체 — 실패해도 예외를 전파하지 않고 로그만 남긴다.
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();
// 매칭용 인덱스: 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>();
let imported = 0;
let relinked = 0;
for (const gr of remote) {
seenGiteaIds.add(gr.id);
const match = byGiteaId.get(gr.id) ?? bySlug.get(gr.name);
if (match) {
// 기존 Relay 저장소 — 연동정보 backfill + 누락표시 해제.
// 표시명/설명/공개범위는 Relay 가 SSOT 이므로 덮어쓰지 않는다.
if (this.backfillLinkage(match, gr)) {
await this.repoRepo.save(match);
relinked++;
}
} else {
// Gitea 에만 있는 저장소 — Relay 로 가져오기(멤버 없음)
const { name, desc } = this.parseDescription(gr.description, gr.name);
const repo = this.repoRepo.create({
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 연동정보를 채우고 누락표시를 해제 — 변경이 있었으면 true
private backfillLinkage(
repo: Repo,
gr: {
id: number;
fullName: string;
cloneUrl: string;
htmlUrl: string;
},
): boolean {
let changed = false;
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 };
}
}
+5 -9
View File
@@ -26,10 +26,10 @@ export class RepoController {
constructor(private readonly repoService: RepoService) {}
@Get()
@ApiOperation({ summary: '내가 멤버인 저장소 목록' })
@ApiOperation({ summary: '조직 저장소 전체 목록' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
findAll(@CurrentUser() user: PublicUser): Promise<RepoResponse[]> {
return this.repoService.findAllForUser(user.id);
findAll(): Promise<RepoResponse[]> {
return this.repoService.findAll();
}
@Post()
@@ -61,13 +61,9 @@ export class RepoController {
@Get(':repoId')
@ApiOperation({ summary: '저장소 단건 조회' })
@ApiResponse({ status: 200, description: '조회 성공' })
@ApiResponse({ status: 403, description: '접근 권한 없음 (AUTH_003)' })
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
findOne(
@Param('repoId') repoId: string,
@CurrentUser() user: PublicUser,
): Promise<RepoResponse> {
return this.repoService.findOneForUser(repoId, user.id);
findOne(@Param('repoId') repoId: string): Promise<RepoResponse> {
return this.repoService.findOne(repoId);
}
@Patch(':repoId')
+3 -2
View File
@@ -8,8 +8,9 @@ import { RepoService } from './repo.service';
import { RepoController } from './repo.controller';
import { MemberService } from './member.service';
import { MemberController } from './member.controller';
import { RepoSyncService } from './repo-sync.service';
// 저장소 모듈 (저장소 + 멤버 관리)
// 저장소 모듈 (저장소 + 멤버 관리 + Gitea 동기화)
@Module({
imports: [
TypeOrmModule.forFeature([Repo, RepoMember]),
@@ -17,7 +18,7 @@ import { MemberController } from './member.controller';
GiteaModule,
],
controllers: [RepoController, MemberController],
providers: [RepoService, MemberService],
providers: [RepoService, MemberService, RepoSyncService],
exports: [RepoService, MemberService],
})
export class RepoModule {}
+8 -20
View File
@@ -6,7 +6,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import { UserService, type PublicUser } from '../user/user.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import {
@@ -40,6 +40,7 @@ export interface RepoResponse {
totalCount: number;
cloneUrl: string | null; // Gitea HTTPS clone URL (미연동 시 null)
htmlUrl: string | null; // Gitea 웹 UI URL (미연동 시 null)
giteaMissing: boolean; // Gitea 조직에서 사라진 저장소 표시(동기화로 갱신)
updatedAt: string;
}
@@ -73,33 +74,19 @@ export class RepoService {
};
}
// 내가 멤버인 저장소 목록
async findAllForUser(userId: string): Promise<RepoResponse[]> {
const memberships = await this.memberRepo.find({
where: { user: { id: userId } },
relations: ['repo'],
});
const repoIds = memberships.map((m) => m.repo.id);
if (repoIds.length === 0) return [];
// 조직 저장소 전체 목록(생성 출처 무관) — Relay 생성분 + Gitea 동기화분.
// 멤버십은 저장소 내 역할/권한에만 쓰이고 목록 노출에는 영향을 주지 않는다(단일 조직 모델).
async findAll(): Promise<RepoResponse[]> {
const repos = await this.repoRepo.find({
where: { id: In(repoIds) },
relations: ['members', 'members.user'],
order: { updatedAt: 'DESC' },
});
return repos.map((repo) => this.toResponse(repo));
}
// 저장소 단건 — 접근 권한 확인(멤버이거나 공개)
async findOneForUser(
slugName: string,
userId: string,
): Promise<RepoResponse> {
// 저장소 단건 — 조직 모델상 인증 사용자면 열람 가능(쓰기 권한은 별도 가드).
async findOne(slugName: string): Promise<RepoResponse> {
const repo = await this.getEntityOrThrow(slugName);
const isMember = repo.members.some((m) => m.user.id === userId);
if (!isMember && repo.visibility !== 'public') {
throw new ForbiddenException();
}
return this.toResponse(repo);
}
@@ -350,6 +337,7 @@ export class RepoService {
totalCount: 0,
cloneUrl: repo.cloneUrl,
htmlUrl: repo.htmlUrl,
giteaMissing: repo.giteaMissing,
updatedAt: repo.updatedAt.toISOString(),
};
}