feat: 저장소 설정 권한 게이팅 + Gitea 역동기화 + 설정 로직 정비
설정 탭/권한: - findOne/update 응답에 현재 사용자 권한(canManage=admin, isOwner=소유자) 포함 (목록은 캐시·사용자 무관이라 미포함). RepoTabs 가 canManage 일 때만 설정 탭 노출, 비관리자의 설정 화면 직접 진입은 저장소 상세로 리다이렉트. 입력 비활성화 처리 제거. - 저장소 삭제는 isOwner 일 때만(비소유자 admin 엔 비활성+안내). 설정 로직 버그/정리: - update 가 이전 값과 비교해 실제 변경분만 활동/Gitea/캐시 반영 (무변경 저장 시 허위 'renamed'/'visibility_changed' 활동이 쌓이던 버그 수정). - 위험구역의 미구현 'Gitea 저장소 이름 변경'(slug rename) 스텁 제거. - 조직 owner 아바타(하드코딩 'M') 제거. Gitea 역동기화(Gitea=SSOT): - 동기화 매칭 시 slug(rename)·공개범위·기본 브랜치를 Gitea 기준으로 끌어옴 → Gitea 에서 직접 바꿔도 Relay 가 따라가고 slugName 도 갱신돼 쓰기 경로 일치 (rename 후 옛 이름으로 쓰기 호출이 404 나던 desync 해소). 중복 매칭 가드 추가. - 표시명/설명은 Gitea 단일 description 합본 구조라 Relay-SSOT 유지(외부 편집 시 깨짐 방지). - GiteaService.updateRepo 에 default_branch 추가 + update 가 브랜치도 Gitea 에 푸시(양방향 일치). 검증: 백엔드 build/lint 0·20 tests, 프론트 type-check/lint/build 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -129,10 +129,11 @@ export class GiteaService {
|
||||
return this.toResult(data);
|
||||
}
|
||||
|
||||
// 저장소 메타데이터(설명/공개범위) 수정 — slug(name) 는 변경하지 않음
|
||||
// 저장소 메타데이터(설명/공개범위/기본 브랜치) 수정 — slug(name) 는 변경하지 않음.
|
||||
// default_branch 는 Gitea 에 실제로 존재하는 브랜치여야 한다(없으면 Gitea 가 에러).
|
||||
async updateRepo(
|
||||
name: string,
|
||||
patch: { description?: string; private?: boolean },
|
||||
patch: { description?: string; private?: boolean; defaultBranch?: string },
|
||||
): Promise<void> {
|
||||
await this.request<GiteaRepoApi>(
|
||||
'PATCH',
|
||||
@@ -140,6 +141,7 @@ export class GiteaService {
|
||||
{
|
||||
description: patch.description,
|
||||
private: patch.private,
|
||||
default_branch: patch.defaultBranch,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserService } from '../user/user.service';
|
||||
import type { User } from '../user/entities/user.entity';
|
||||
import { GiteaService } from '../gitea/gitea.service';
|
||||
import { GiteaService, type GiteaRepoResult } from '../gitea/gitea.service';
|
||||
import { RepoCacheService } from './repo-cache.service';
|
||||
import { Repo } from './entities/repo.entity';
|
||||
import { RepoMember } from './entities/repo-member.entity';
|
||||
@@ -80,16 +80,23 @@ export class RepoSyncService implements OnApplicationBootstrap {
|
||||
}
|
||||
|
||||
const seenGiteaIds = new Set<number>();
|
||||
// 이번 동기화에서 이미 매칭된 Relay 저장소(uuid) — 중복 매칭 방지
|
||||
const matchedRepoIds = new Set<string>();
|
||||
let imported = 0;
|
||||
let relinked = 0;
|
||||
|
||||
for (const gr of remote) {
|
||||
seenGiteaIds.add(gr.id);
|
||||
const match = byGiteaId.get(gr.id) ?? bySlug.get(gr.name);
|
||||
// Gitea ID 우선, 없으면 옛 slug 로 매칭. 단 이미 매칭된 저장소는 제외
|
||||
// (Gitea 에서 rename 후 옛 이름으로 새 저장소를 만든 경우의 stale-slug 충돌 방지)
|
||||
const candidate = byGiteaId.get(gr.id) ?? bySlug.get(gr.name);
|
||||
const match =
|
||||
candidate && !matchedRepoIds.has(candidate.id) ? candidate : null;
|
||||
if (match) {
|
||||
// 기존 Relay 저장소 — 연동정보 backfill + 누락표시 해제.
|
||||
// 표시명/설명/공개범위는 Relay 가 SSOT 이므로 덮어쓰지 않는다.
|
||||
if (this.backfillLinkage(match, gr)) {
|
||||
matchedRepoIds.add(match.id);
|
||||
// Gitea 를 SSOT 로 — 이름(slug)/표시명/설명/공개범위/브랜치 + 연동정보를 Gitea 기준에 맞춘다.
|
||||
// (Gitea 에서 직접 변경한 내용을 Relay 로 끌어온다. rename 시 slugName 도 따라가 라우팅·쓰기 동기화 일치)
|
||||
if (this.reconcileFromGitea(match, gr)) {
|
||||
await this.repoRepo.save(match);
|
||||
relinked++;
|
||||
}
|
||||
@@ -191,17 +198,31 @@ export class RepoSyncService implements OnApplicationBootstrap {
|
||||
await this.memberRepo.save(member);
|
||||
}
|
||||
|
||||
// 기존 저장소에 Gitea 연동정보를 채우고 누락표시를 해제 — 변경이 있었으면 true
|
||||
private backfillLinkage(
|
||||
repo: Repo,
|
||||
gr: {
|
||||
id: number;
|
||||
fullName: string;
|
||||
cloneUrl: string;
|
||||
htmlUrl: string;
|
||||
},
|
||||
): boolean {
|
||||
// Gitea 를 기준으로 Relay 저장소 메타를 맞춘다 — Gitea 의 네이티브 필드와 깔끔히 1:1 대응되는
|
||||
// 이름(slug)/공개범위/기본 브랜치 + 연동정보만 동기화한다. 변경이 있었으면 true.
|
||||
// ※ 표시명/설명은 Gitea 단일 description 에 "표시제목 — 설명" 으로 합쳐 저장하므로,
|
||||
// Gitea 에서 description 을 직접 편집하면 표시명이 깨질 수 있어 Relay 가 SSOT 로 유지한다.
|
||||
private reconcileFromGitea(repo: Repo, gr: GiteaRepoResult): boolean {
|
||||
let changed = false;
|
||||
|
||||
// 이름(slug) — Gitea rename 반영. Relay 라우팅 URL 과 이후 Gitea 쓰기 경로가 새 이름을 따른다
|
||||
if (repo.slugName !== gr.name) {
|
||||
repo.slugName = gr.name;
|
||||
changed = true;
|
||||
}
|
||||
// 공개범위
|
||||
const visibility = gr.private ? 'private' : 'public';
|
||||
if (repo.visibility !== visibility) {
|
||||
repo.visibility = visibility;
|
||||
changed = true;
|
||||
}
|
||||
// 기본 브랜치
|
||||
if (gr.defaultBranch && repo.branch !== gr.defaultBranch) {
|
||||
repo.branch = gr.defaultBranch;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// 연동정보(linkage) + 누락표시 해제
|
||||
if (repo.giteaRepoId !== gr.id) {
|
||||
repo.giteaRepoId = gr.id;
|
||||
changed = true;
|
||||
|
||||
@@ -69,8 +69,11 @@ export class RepoController {
|
||||
@ApiOperation({ summary: '저장소 단건 조회' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
|
||||
findOne(@Param('repoId') repoId: string): Promise<RepoResponse> {
|
||||
return this.repoService.findOne(repoId);
|
||||
findOne(
|
||||
@Param('repoId') repoId: string,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<RepoResponse> {
|
||||
return this.repoService.findOne(repoId, user.id);
|
||||
}
|
||||
|
||||
@Patch(':repoId')
|
||||
|
||||
@@ -50,6 +50,9 @@ export interface RepoResponse {
|
||||
htmlUrl: string | null; // Gitea 웹 UI URL (미연동 시 null)
|
||||
giteaMissing: boolean; // Gitea 조직에서 사라진 저장소 표시(동기화로 갱신)
|
||||
updatedAt: string;
|
||||
// 현재 사용자의 이 저장소 권한 — 단건(findOne)에서만 채워진다(목록은 캐시·사용자 무관이라 미포함).
|
||||
canManage?: boolean; // admin (설정 변경/탭 노출)
|
||||
isOwner?: boolean; // 소유자 (저장소 삭제)
|
||||
}
|
||||
|
||||
// 저장소 비즈니스 로직
|
||||
@@ -114,9 +117,16 @@ export class RepoService {
|
||||
}
|
||||
|
||||
// 저장소 단건 — 조직 모델상 인증 사용자면 열람 가능(쓰기 권한은 별도 가드).
|
||||
async findOne(slugName: string): Promise<RepoResponse> {
|
||||
// 응답에 현재 사용자의 권한(canManage/isOwner)을 함께 내려, 프론트가 설정 탭 노출·진입을 결정한다.
|
||||
async findOne(slugName: string, userId: string): Promise<RepoResponse> {
|
||||
const repo = await this.getEntityOrThrow(slugName);
|
||||
return this.toResponse(repo, await this.taskCountsOf(repo.id));
|
||||
const membership = await this.memberRepo.findOne({
|
||||
where: { repo: { id: repo.id }, user: { id: userId } },
|
||||
});
|
||||
return this.toResponse(repo, await this.taskCountsOf(repo.id), {
|
||||
canManage: membership?.roleType === 'admin',
|
||||
isOwner: membership?.isOwner ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
// 저장소 생성 — Gitea 조직에 실제 repo 를 만들고, 생성자를 소유자(admin)로 등록
|
||||
@@ -218,9 +228,12 @@ 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;
|
||||
// 변경 감지 — 폼이 모든 필드를 항상 보내므로, 실제로 바뀐 값만 동기화·활동·캐시에 반영한다
|
||||
// (이전엔 필드 존재 여부로 판단해 저장할 때마다 허위 'renamed'/'visibility_changed' 활동이 쌓였음)
|
||||
const prevName = repo.name;
|
||||
const prevDescription = repo.description ?? '';
|
||||
const prevVisibility = repo.visibility;
|
||||
const prevBranch = repo.branch;
|
||||
|
||||
if (dto.name !== undefined) repo.name = dto.name;
|
||||
if (dto.description !== undefined) repo.description = dto.description;
|
||||
@@ -228,20 +241,30 @@ export class RepoService {
|
||||
if (dto.branch !== undefined && dto.branch.trim())
|
||||
repo.branch = dto.branch.trim();
|
||||
|
||||
const nameChanged = repo.name !== prevName;
|
||||
const descriptionChanged = (repo.description ?? '') !== prevDescription;
|
||||
const visibilityChanged = repo.visibility !== prevVisibility;
|
||||
const branchChanged = repo.branch !== prevBranch;
|
||||
|
||||
await this.repoRepo.save(repo);
|
||||
|
||||
// Gitea 메타데이터 동기화 (베스트 에포트 — 실패해도 Relay 수정은 유지)
|
||||
// Gitea 메타데이터 동기화 (베스트 에포트) — 표시명/설명/공개범위/브랜치가 실제 바뀐 경우에만.
|
||||
// (브랜치도 Gitea→Relay 로 역동기화되므로, Relay 변경분을 Gitea 에 밀어 양쪽을 일치시킨다)
|
||||
const giteaDescChanged = nameChanged || descriptionChanged;
|
||||
if (
|
||||
this.gitea.enabled &&
|
||||
repo.giteaRepoId !== null &&
|
||||
(descChanged || visChanged)
|
||||
(giteaDescChanged || visibilityChanged || branchChanged)
|
||||
) {
|
||||
await this.gitea
|
||||
.updateRepo(repo.slugName, {
|
||||
description: descChanged
|
||||
description: giteaDescChanged
|
||||
? this.buildGiteaDescription(repo.name, repo.description)
|
||||
: undefined,
|
||||
private: visChanged ? repo.visibility === 'private' : undefined,
|
||||
private: visibilityChanged
|
||||
? repo.visibility === 'private'
|
||||
: undefined,
|
||||
defaultBranch: branchChanged ? repo.branch : undefined,
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
this.logger.error(
|
||||
@@ -251,8 +274,8 @@ export class RepoService {
|
||||
});
|
||||
}
|
||||
|
||||
// 활동 적재 — 표시 제목 변경 / 공개범위 변경(설정 그룹)
|
||||
if (dto.name !== undefined) {
|
||||
// 활동 적재 — 실제로 바뀐 측면만(표시 제목 변경 / 공개범위 변경)
|
||||
if (nameChanged) {
|
||||
await this.activity.record({
|
||||
repoId: repo.id,
|
||||
actorId: userId,
|
||||
@@ -260,7 +283,7 @@ export class RepoService {
|
||||
payload: { newName: repo.name },
|
||||
});
|
||||
}
|
||||
if (visChanged) {
|
||||
if (visibilityChanged) {
|
||||
await this.activity.record({
|
||||
repoId: repo.id,
|
||||
actorId: userId,
|
||||
@@ -269,12 +292,27 @@ export class RepoService {
|
||||
});
|
||||
}
|
||||
|
||||
// 목록 캐시 무효화(표시명/공개범위 변경 즉시 반영)
|
||||
await this.repoCache.invalidateList();
|
||||
// 목록 캐시 무효화 — 목록 카드에 노출되는 값(표시명/설명/공개범위/브랜치)이 바뀐 경우에만
|
||||
if (
|
||||
nameChanged ||
|
||||
descriptionChanged ||
|
||||
visibilityChanged ||
|
||||
branchChanged
|
||||
) {
|
||||
await this.repoCache.invalidateList();
|
||||
}
|
||||
|
||||
// 응답에도 현재 사용자 권한을 포함(프론트가 저장 후에도 canManage/isOwner 를 유지하도록)
|
||||
const membership = await this.memberRepo.findOne({
|
||||
where: { repo: { id: repo.id }, user: { id: userId } },
|
||||
});
|
||||
return this.toResponse(
|
||||
await this.getEntityOrThrow(slugName),
|
||||
await this.taskCountsOf(repo.id),
|
||||
{
|
||||
canManage: membership?.roleType === 'admin',
|
||||
isOwner: membership?.isOwner ?? false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -391,10 +429,11 @@ export class RepoService {
|
||||
return (await this.taskService.countsByRepo([repoId])).get(repoId);
|
||||
}
|
||||
|
||||
// 엔티티 → 응답 매핑 (counts 미제공 시 0/0)
|
||||
// 엔티티 → 응답 매핑 (counts 미제공 시 0/0). perms 는 단건 조회에서만 채운다.
|
||||
private toResponse(
|
||||
repo: Repo,
|
||||
counts?: { done: number; total: number },
|
||||
perms?: { canManage: boolean; isOwner: boolean },
|
||||
): RepoResponse {
|
||||
const members = (repo.members ?? []).map((m) =>
|
||||
UserService.toPublic(m.user),
|
||||
@@ -416,6 +455,8 @@ export class RepoService {
|
||||
htmlUrl: repo.htmlUrl,
|
||||
giteaMissing: repo.giteaMissing,
|
||||
updatedAt: repo.updatedAt.toISOString(),
|
||||
canManage: perms?.canManage,
|
||||
isOwner: perms?.isOwner,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user