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:
2026-06-19 15:39:41 +09:00
parent 56c89d9927
commit 8935f6e488
14 changed files with 154 additions and 79 deletions
+4 -2
View File
@@ -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,
},
);
}
+36 -15
View File
@@ -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;
+5 -2
View File
@@ -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')
+56 -15
View File
@@ -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,
};
}
}