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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
+12
-1
@@ -79,7 +79,7 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
|
||||
|
||||
| 메서드 | 경로 | 요청 | 응답 |
|
||||
|---|---|---|---|
|
||||
| GET | `/repos` | `?page&size` | `Repo[]`(요약) — 내가 멤버인 저장소 |
|
||||
| GET | `/repos` | `?page&size` | `Repo[]`(요약) — **조직 저장소 전체**(생성 출처 무관) |
|
||||
| GET | `/repos/meta` | — | `{ owner, template }` — 생성 폼 메타(`:repoId` 보다 먼저 매칭) |
|
||||
| POST | `/repos` | `CreateRepoDto` | `Repo` |
|
||||
| GET | `/repos/:repoId` | — | `Repo`(상세) |
|
||||
@@ -108,10 +108,12 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
|
||||
"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
|
||||
"giteaMissing": false, // Gitea 조직에서 사라진 저장소 표시(동기화로 갱신)
|
||||
"updatedAt": "2026-06-16T11:00:00Z"
|
||||
}
|
||||
```
|
||||
> `moreCount`, `progressPct/Level/Label`, `updatedAgo` 는 프론트 계산.
|
||||
> `GET /repos` 는 단일 조직 모델상 **조직의 모든 저장소**를 반환한다(멤버십은 역할/권한에만 사용). Gitea-동기화로 import 된 저장소는 멤버 0명으로 노출되며, `giteaMissing:true` 면 프론트에서 '원격 없음' 배지로 표기한다.
|
||||
> 매핑 페이지: RepoListPage, RepoCreatePage, RepoDetailPage(헤더), RepoSettingsPage.
|
||||
|
||||
#### Gitea 연동 (외부 인스턴스)
|
||||
@@ -132,6 +134,15 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
|
||||
- Gitea 이름 충돌(409) → `BIZ_001` "이미 사용 중인 저장소 이름입니다.", 그 외 실패 → `BIZ_001`(502).
|
||||
- 수정/삭제의 Gitea 동기화는 **베스트 에포트**(실패해도 Relay 상태는 반영, 에러 로그만 남김).
|
||||
|
||||
##### Gitea → Relay 역방향 동기화 (주기적 import)
|
||||
|
||||
조직에 직접 만든 저장소도 Relay 에 보이도록, **서버 기동 직후 1회 + 매시간 정각**에 조직 저장소 목록(`GET /api/v1/orgs/{org}/repos`)을 받아 Relay 와 맞춘다(`RepoSyncService`).
|
||||
|
||||
- **추가형(additive)**: Gitea ID → slug 순으로 매칭. Relay 에 없으면 import(멤버 0명), 양쪽에 있으면 연동정보(`giteaRepoId`/`cloneUrl`/`htmlUrl`)만 backfill. 표시명/설명/공개범위는 import 이후 **Relay 가 SSOT**(재동기화로 덮어쓰지 않음).
|
||||
- **import 표시명**: Gitea description 의 `"표시제목 — 설명"` 규칙을 파싱(구분자 없으면 slug 를 표시명, 전체를 설명으로).
|
||||
- **누락 표기**: Gitea 에서 사라진(연동 origin) 저장소는 **삭제하지 않고** `giteaMissing:true` 로만 표기. 다시 나타나면 false 로 복구.
|
||||
- Gitea 미연동(`GITEA_*` 미설정) 시 동기화는 건너뛴다. 중복 실행 방지 가드 포함.
|
||||
|
||||
---
|
||||
|
||||
## 3. 멤버 (`/api/repos/:repoId/members`) — **3단계**
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
> 프론트 UI(목업)를 NestJS 백엔드에 단계적으로 연동하는 작업의 진행 기록.
|
||||
> 계약/스키마 상세는 [`api-contract.md`](./api-contract.md) 참조.
|
||||
> 최종 갱신: 2026-06-17 · 현재 위치: **0~3단계 완료(+ Gitea 연동·템플릿 복제·생성폼 보강), 4단계(업무) 대기**
|
||||
> 남은 프론트 연동: RepoMembersPage(3단계), cloneUrl/htmlUrl 표시(2.5단계). 그 외 4~8단계 미착수.
|
||||
> 최종 갱신: 2026-06-17 · 현재 위치: **0~3단계 완료(+ Gitea 양방향 연동·템플릿 복제·생성폼 보강), 4단계(업무) 대기**
|
||||
> 남은 프론트 연동: RepoMembersPage(3단계), cloneUrl/htmlUrl 표시·giteaMissing 배지(2.5단계). 그 외 4~8단계 미착수.
|
||||
|
||||
---
|
||||
|
||||
@@ -78,6 +78,19 @@
|
||||
|
||||
> **TODO(프론트)**: RepoSettingsPage/RepoDetailPage 에 `cloneUrl`/`htmlUrl` 표시(클론 주소 복사, Gitea 열기 버튼). 백엔드 응답에는 이미 포함.
|
||||
|
||||
### 2.6단계 — Gitea → Relay 역방향 동기화 ✅ (백엔드)
|
||||
|
||||
**백엔드** `backend/src/modules/gitea/`, `modules/repo/repo-sync.service.ts`
|
||||
- 문제: 동기화가 단방향(Relay→Gitea)이라 **Gitea 조직에 직접 만든 저장소가 Relay 목록에 안 떴음**.
|
||||
- `GiteaService.listOrgRepos()`: 조직 저장소 페이지네이션 순회(`GET /orgs/{org}/repos`). 결과에 `name`/`description`/`private` 추가.
|
||||
- `RepoSyncService`: **서버 기동 직후 1회(`OnApplicationBootstrap`, 비차단) + 매시간(`@Cron(EVERY_HOUR)`)** 동기화. `ScheduleModule.forRoot()` 는 `app.module` 에 이미 등록됨.
|
||||
- **추가형**: Gitea ID→slug 매칭. 없으면 import(멤버 0명), 있으면 연동정보만 backfill. 표시명/설명/공개범위는 import 후 Relay 가 SSOT.
|
||||
- **누락 표기**: Gitea 에서 사라진 연동 origin 저장소는 삭제하지 않고 `giteaMissing:true` 로만 표기(재출현 시 복구). `Repo.giteaMissing` 컬럼 추가, `RepoResponse`/프론트 `ApiRepo` 에 노출.
|
||||
- 미연동 시 건너뜀 + 중복 실행 가드.
|
||||
- **목록 모델 변경**: `GET /repos` 가 '내 멤버 저장소' → **'조직 저장소 전체'**(`findAll`). 단건(`findOne`)도 조직 모델상 인증 사용자 열람 허용(쓰기 권한은 기존 admin/owner 유지). 멤버십은 역할/권한 전용.
|
||||
|
||||
> **TODO(프론트)**: RepoListPage 에 `giteaMissing` '원격 없음' 배지 표시. import 된 멤버 0명 저장소의 빈 아바타/0% 진행률은 정상.
|
||||
|
||||
### 3단계 — 멤버 관리 ✅ (백엔드)
|
||||
|
||||
**백엔드** `backend/src/modules/repo/`
|
||||
|
||||
@@ -21,6 +21,9 @@ export interface ApiRepo {
|
||||
memberCount: number
|
||||
doneCount: number
|
||||
totalCount: number
|
||||
cloneUrl: string | null // Gitea HTTPS clone URL (미연동 시 null)
|
||||
htmlUrl: string | null // Gitea 웹 UI URL (미연동 시 null)
|
||||
giteaMissing: boolean // Gitea 조직에서 사라진 저장소 표시(동기화로 갱신)
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user