Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42db9b5d00 | |||
| 8935f6e488 | |||
| 56c89d9927 |
@@ -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';
|
||||
@@ -50,10 +50,10 @@ export class RepoSyncService implements OnApplicationBootstrap {
|
||||
void this.syncFromGitea('부트스트랩');
|
||||
}
|
||||
|
||||
// 매시간 정각 동기화
|
||||
@Cron(CronExpression.EVERY_HOUR)
|
||||
async handleHourlySync(): Promise<void> {
|
||||
await this.syncFromGitea('스케줄(매시간)');
|
||||
// 10분마다 동기화
|
||||
@Cron(CronExpression.EVERY_10_MINUTES)
|
||||
async handleScheduledSync(): Promise<void> {
|
||||
await this.syncFromGitea('스케줄(10분)');
|
||||
}
|
||||
|
||||
// 조직 저장소 동기화 본체 — 실패해도 예외를 전파하지 않고 로그만 남긴다.
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,9 +115,12 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
|
||||
"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"
|
||||
"updatedAt": "2026-06-16T11:00:00Z",
|
||||
"canManage": true, // 현재 사용자 admin 여부 — 단건(GET /repos/:id)에서만. 설정 탭 노출/진입·수정 권한
|
||||
"isOwner": true // 현재 사용자 소유자 여부 — 저장소 삭제 권한
|
||||
}
|
||||
```
|
||||
> `canManage`/`isOwner` 는 **단건 조회·수정 응답에만** 포함된다(목록 `GET /repos` 는 캐시·사용자 무관이라 미포함). 프론트는 이 값으로 **설정 탭을 admin 에게만 노출**하고, 비admin 의 설정 화면 직접 진입을 저장소 상세로 리다이렉트한다. 삭제 버튼은 `isOwner` 일 때만 활성.
|
||||
> `moreCount`, `progressPct/Level/Label`, `updatedAgo` 는 프론트 계산.
|
||||
> `GET /repos` 는 단일 조직 모델상 **조직의 모든 저장소**를 반환한다(멤버십은 역할/권한에만 사용). Gitea-동기화로 import 된 저장소는 멤버 0명으로 노출되며, `giteaMissing:true` 면 프론트에서 '원격 없음' 배지로 표기한다.
|
||||
> 매핑 페이지: RepoListPage, RepoCreatePage, RepoDetailPage(헤더), RepoSettingsPage.
|
||||
@@ -142,9 +145,9 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
|
||||
|
||||
##### Gitea → Relay 역방향 동기화 (주기적 import)
|
||||
|
||||
조직에 직접 만든 저장소도 Relay 에 보이도록, **서버 기동 직후 1회 + 매시간 정각**에 조직 저장소 목록(`GET /api/v1/orgs/{org}/repos`)을 받아 Relay 와 맞춘다(`RepoSyncService`).
|
||||
조직에 직접 만든 저장소도 Relay 에 보이도록, **서버 기동 직후 1회 + 10분마다**에 조직 저장소 목록(`GET /api/v1/orgs/{org}/repos`)을 받아 Relay 와 맞춘다(`RepoSyncService`).
|
||||
|
||||
- **추가형(additive)**: Gitea ID → slug 순으로 매칭. Relay 에 없으면 import(멤버 0명), 양쪽에 있으면 연동정보(`giteaRepoId`/`cloneUrl`/`htmlUrl`)만 backfill. 표시명/설명/공개범위는 import 이후 **Relay 가 SSOT**(재동기화로 덮어쓰지 않음).
|
||||
- **추가형 + 역동기화(reconcile)**: Gitea ID → slug 순으로 매칭(이번 동기화에서 이미 매칭된 Relay 저장소는 중복 매칭 제외 — rename 후 옛 이름 재사용 충돌 방지). Relay 에 없으면 import(멤버 0명). 매칭되면 **Gitea 의 네이티브 필드와 1:1 대응되는 값을 Gitea 기준(SSOT)으로 끌어온다**: `slugName`(Gitea rename → Relay 라우팅·쓰기 경로가 새 이름을 따름), `visibility`(private 토글), `branch`(기본 브랜치), + 연동정보(`giteaRepoId`/`giteaFullName`/`cloneUrl`/`htmlUrl`, 누락표시 해제). **단 표시명(`name`)·설명(`description`)은 Gitea 단일 `description` 에 "표시제목 — 설명" 으로 합쳐 저장하므로(표시명 필드 부재), Gitea 에서 description 을 직접 편집하면 표시명이 깨질 수 있어 Relay 가 SSOT 로 유지**(역동기화 제외). Relay→Gitea 쓰기(`PATCH`)는 설명/공개범위에 더해 **기본 브랜치(`default_branch`)도 푸시**해 양방향을 일치시킨다(브랜치는 Gitea 에 실제 존재해야 함).
|
||||
- **import 표시명**: Gitea description 의 `"표시제목 — 설명"` 규칙을 파싱(구분자 없으면 slug 를 표시명, 전체를 설명으로).
|
||||
- **누락 표기**: Gitea 에서 사라진(연동 origin) 저장소는 **삭제하지 않고** `giteaMissing:true` 로만 표기. 다시 나타나면 false 로 복구.
|
||||
- Gitea 미연동(`GITEA_*` 미설정) 시 동기화는 건너뛴다. 중복 실행 방지 가드 포함.
|
||||
|
||||
@@ -83,8 +83,8 @@
|
||||
**백엔드** `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.
|
||||
- `RepoSyncService`: **서버 기동 직후 1회(`OnApplicationBootstrap`, 비차단) + 10분마다(`@Cron(EVERY_10_MINUTES)`)** 동기화. `ScheduleModule.forRoot()` 는 `app.module` 에 이미 등록됨.
|
||||
- **추가형 + 역동기화(reconcile, 2026-06-19 확장)**: Gitea ID→slug 매칭(중복 매칭 가드). 없으면 import(멤버 0명). 매칭되면 Gitea 네이티브 필드와 1:1 대응되는 **slug(rename)·공개범위·기본 브랜치 + 연동정보를 Gitea 기준(SSOT)으로 끌어온다** → Gitea 에서 직접 이름/공개범위/브랜치를 바꿔도 Relay 가 따라가고 쓰기 경로(slugName)도 일치. **표시명/설명은 Gitea 단일 description 합본 구조라 Relay-SSOT 유지**(외부 편집 시 표시명 깨짐 방지). Relay→Gitea 수정은 `default_branch` 도 함께 푸시(양방향 일치).
|
||||
- **누락 표기**: Gitea 에서 사라진 연동 origin 저장소는 삭제하지 않고 `giteaMissing:true` 로만 표기(재출현 시 복구). `Repo.giteaMissing` 컬럼 추가, `RepoResponse`/프론트 `ApiRepo` 에 노출.
|
||||
- 미연동 시 건너뜀 + 중복 실행 가드.
|
||||
- **import 저장소 멤버 데드락 해소(2026-06-17)**: import 저장소는 멤버 0명이라 admin 이 없어 아무도 멤버를 초대할 수 없는 막힘이 있었음. 동기화 끝에 **`REPO_IMPORT_ADMIN_EMAIL`(env) 의 가입 사용자를 멤버 0명인 Gitea-연동 저장소에 owner-admin 으로 자동 지정**(신규 import + 기존 멤버 0명 모두 대상, Relay 생성분은 생성자가 이미 멤버라 제외). env 미설정/미가입이면 건너뜀(가입 후 다음 동기화에서 지정). 키 문서화: `backend/.env.{env}.example`.
|
||||
|
||||
@@ -7,6 +7,8 @@ interface Props {
|
||||
active: 'tasks' | 'members' | 'activity' | 'settings'
|
||||
taskCount: number
|
||||
memberCount: number
|
||||
/** 설정 탭 노출 여부(관리자만). 기본 false — 권한이 확인된 경우에만 노출 */
|
||||
canManage?: boolean
|
||||
}
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
@@ -32,6 +34,7 @@ defineProps<Props>()
|
||||
<span class="tb-label">활동</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="canManage"
|
||||
:to="`/repos/${repoId}/settings`"
|
||||
:class="{ active: active === 'settings' }"
|
||||
>
|
||||
|
||||
@@ -65,6 +65,10 @@ export interface Repo {
|
||||
htmlUrl?: string | null
|
||||
/** Gitea 조직에서 사라진 저장소 표시(역방향 동기화로 갱신) */
|
||||
giteaMissing?: boolean
|
||||
/** 현재 사용자가 이 저장소 관리자(admin)인지 — 단건 조회에서만 채워짐(설정 탭 노출/진입) */
|
||||
canManage?: boolean
|
||||
/** 현재 사용자가 소유자인지 — 저장소 삭제 권한 */
|
||||
isOwner?: boolean
|
||||
}
|
||||
|
||||
/** 저장소 상세의 업무 목록 행 */
|
||||
|
||||
@@ -111,6 +111,7 @@ const ActIcon = defineComponent({
|
||||
active="activity"
|
||||
:task-count="repo.totalCount"
|
||||
:member-count="memberCount"
|
||||
:can-manage="repo.canManage"
|
||||
/>
|
||||
|
||||
<!-- 툴바 -->
|
||||
|
||||
@@ -165,6 +165,7 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
active="tasks"
|
||||
:task-count="counts.all"
|
||||
:member-count="memberCount"
|
||||
:can-manage="repo.canManage"
|
||||
/>
|
||||
|
||||
<!-- 툴바 -->
|
||||
|
||||
@@ -232,6 +232,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
active="members"
|
||||
:task-count="repo.totalCount"
|
||||
:member-count="counts.all"
|
||||
:can-manage="repo.canManage"
|
||||
/>
|
||||
|
||||
<div class="tab-note">
|
||||
|
||||
@@ -15,6 +15,10 @@ const repoId = computed(() => String(route.params.repoId))
|
||||
const repoStore = useRepoStore()
|
||||
|
||||
const repo = ref<Repo | null>(null)
|
||||
|
||||
// 현재 사용자 권한 — 단건 응답에 포함된 값(백엔드와 동일 정책: 설정=admin, 삭제=소유자)
|
||||
const canManage = computed(() => repo.value?.canManage === true)
|
||||
const isOwner = computed(() => repo.value?.isOwner === true)
|
||||
const headerMembers = computed(() => repo.value?.members ?? [])
|
||||
const memberCount = computed(() =>
|
||||
repo.value ? repo.value.members.length + repo.value.moreCount : 0,
|
||||
@@ -36,6 +40,11 @@ async function loadRepo() {
|
||||
return
|
||||
}
|
||||
if (!repo.value) return
|
||||
// 비관리자는 설정 화면에 진입할 수 없음 — 저장소 상세로 되돌린다(탭도 숨겨져 있어 정상 경로로는 도달 불가)
|
||||
if (!repo.value.canManage) {
|
||||
void router.replace(`/repos/${repoId.value}`)
|
||||
return
|
||||
}
|
||||
displayTitle.value = repo.value.name
|
||||
description.value = repo.value.desc
|
||||
visibility.value = repo.value.visibility
|
||||
@@ -43,9 +52,9 @@ async function loadRepo() {
|
||||
}
|
||||
watch(repoId, loadRepo, { immediate: true })
|
||||
|
||||
// 변경 저장
|
||||
// 변경 저장 (admin 전용)
|
||||
async function save() {
|
||||
if (saving.value || !repo.value) return
|
||||
if (saving.value || !repo.value || !canManage.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
repo.value = await repoStore.update(repoId.value, {
|
||||
@@ -62,9 +71,9 @@ async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
// 저장소 삭제
|
||||
// 저장소 삭제 (소유자 전용)
|
||||
async function removeRepo() {
|
||||
if (!repo.value) return
|
||||
if (!repo.value || !isOwner.value) return
|
||||
if (!window.confirm('저장소와 그 안의 모든 업무·활동 기록이 영구 삭제됩니다. 계속할까요?')) return
|
||||
try {
|
||||
await repoStore.remove(repoId.value)
|
||||
@@ -73,17 +82,12 @@ async function removeRepo() {
|
||||
// 인터셉터 전역 처리
|
||||
}
|
||||
}
|
||||
|
||||
// 저장소 이름(slug) 변경 — 8단계 이후 지원 예정
|
||||
function renameRepo() {
|
||||
window.alert('저장소 이름(slug) 변경은 준비 중입니다.')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div
|
||||
v-if="repo"
|
||||
v-if="repo && repo.canManage"
|
||||
class="page"
|
||||
>
|
||||
<div class="crumb">
|
||||
@@ -115,6 +119,7 @@ function renameRepo() {
|
||||
active="settings"
|
||||
:task-count="repo.totalCount"
|
||||
:member-count="memberCount"
|
||||
:can-manage="repo.canManage"
|
||||
/>
|
||||
|
||||
<div class="settings">
|
||||
@@ -166,7 +171,7 @@ function renameRepo() {
|
||||
Gitea 저장소 이름(영문)은 주소에 사용됩니다. 변경은 아래 위험 구역에서 할 수 있습니다.
|
||||
</div>
|
||||
<div class="readonly">
|
||||
<span class="owner-avatar">M</span> {{ repo.owner }} <span class="slash">/</span> <span class="mono">{{ repo.id }}</span>
|
||||
{{ repo.owner }} <span class="slash">/</span> <span class="mono">{{ repo.id }}</span>
|
||||
<span class="lock">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -318,23 +323,6 @@ function renameRepo() {
|
||||
위험 구역
|
||||
</h2>
|
||||
<div class="danger-card">
|
||||
<div class="danger-row">
|
||||
<div class="dr-main">
|
||||
<div class="dr-title">
|
||||
Gitea 저장소 이름 변경
|
||||
</div>
|
||||
<div class="dr-desc">
|
||||
영문 저장소 이름과 Gitea 주소가 함께 바뀝니다. 기존 링크가 동작하지 않을 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn-danger"
|
||||
type="button"
|
||||
@click="renameRepo"
|
||||
>
|
||||
이름 변경
|
||||
</button>
|
||||
</div>
|
||||
<div class="danger-row">
|
||||
<div class="dr-main">
|
||||
<div class="dr-title">
|
||||
@@ -342,11 +330,16 @@ function renameRepo() {
|
||||
</div>
|
||||
<div class="dr-desc">
|
||||
저장소와 그 안의 모든 업무·활동 기록이 영구 삭제됩니다. 되돌릴 수 없습니다.
|
||||
<span
|
||||
v-if="!isOwner"
|
||||
class="owner-only"
|
||||
>소유자만 삭제할 수 있습니다.</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn-danger solid"
|
||||
type="button"
|
||||
:disabled="!isOwner"
|
||||
@click="removeRepo"
|
||||
>
|
||||
저장소 삭제
|
||||
@@ -447,6 +440,17 @@ function renameRepo() {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.btn-danger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-danger:disabled:hover {
|
||||
background: var(--red);
|
||||
}
|
||||
.owner-only {
|
||||
color: var(--red);
|
||||
font-weight: 600;
|
||||
}
|
||||
textarea.tarea {
|
||||
min-height: 5.25rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
@@ -473,17 +477,6 @@ textarea.tarea {
|
||||
font-weight: 500;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.readonly .owner-avatar {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 0.3125rem;
|
||||
background: #0ea5a3;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.656rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.readonly .slash {
|
||||
color: var(--text-3);
|
||||
}
|
||||
@@ -675,9 +668,6 @@ textarea.tarea {
|
||||
flex-shrink: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background: var(--red-weak);
|
||||
}
|
||||
.btn-danger.solid {
|
||||
background: var(--red);
|
||||
border-color: var(--red);
|
||||
|
||||
@@ -6,14 +6,9 @@ import {
|
||||
escapeHtml,
|
||||
monthDayKo,
|
||||
relativeTimeKo,
|
||||
taskStatusLabel,
|
||||
} from '@/shared/utils/format'
|
||||
import type { ApiActivity } from '@/types/activity'
|
||||
import type {
|
||||
RepoActivityDay,
|
||||
RepoActivityItem,
|
||||
TaskStatus,
|
||||
} from '@/mock/relay.mock'
|
||||
import type { RepoActivityDay, RepoActivityItem } from '@/mock/relay.mock'
|
||||
|
||||
// payload(자유형)에서 문자열 값 안전 추출
|
||||
function pstr(payload: Record<string, unknown>, key: string): string {
|
||||
@@ -71,18 +66,24 @@ function toRepoActivityItem(api: ApiActivity): RepoActivityItem {
|
||||
case 'task.created':
|
||||
return { ...base, tone: 'task', icon: 'pencil', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무를 지시했습니다` }
|
||||
case 'task.status_changed': {
|
||||
// 알림·업무 상세 활동 탭과 동일한 행위 중심 문구(from→to 로 파생)
|
||||
const from = pstr(p, 'statusFrom')
|
||||
const to = pstr(p, 'statusTo')
|
||||
const tgt = `<span class="tgt">${title}</span>`
|
||||
if (to === 'prog') {
|
||||
const verb = from === 'changes' ? '다시 시작' : '시작'
|
||||
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님이 ${tgt} 업무를 <span class="st prog">${verb}</span>했습니다` }
|
||||
}
|
||||
if (to === 'review') {
|
||||
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님이 ${tgt} 업무의 승인을 요청했습니다` }
|
||||
}
|
||||
if (to === 'changes') {
|
||||
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님이 ${tgt} 업무의 수정을 요청했습니다` }
|
||||
}
|
||||
if (to === 'done') {
|
||||
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무를 <span class="st done">완료</span>했습니다` }
|
||||
}
|
||||
const label = taskStatusLabel(to as TaskStatus)
|
||||
const cls = to === 'prog' ? 'prog' : ''
|
||||
return {
|
||||
...base,
|
||||
tone: 'task',
|
||||
icon: 'check',
|
||||
html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무를 <span class="st ${cls}">${escapeHtml(label)}</span>(으)로 변경했습니다`,
|
||||
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님이 ${tgt} 업무를 <span class="st done">완료(승인)</span>했습니다` }
|
||||
}
|
||||
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님이 ${tgt} 업무의 상태를 변경했습니다` }
|
||||
}
|
||||
case 'task.comment_added':
|
||||
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무에 댓글을 남겼습니다` }
|
||||
|
||||
@@ -42,6 +42,8 @@ function toRepoView(api: ApiRepo): RepoView {
|
||||
cloneUrl: api.cloneUrl,
|
||||
htmlUrl: api.htmlUrl,
|
||||
giteaMissing: api.giteaMissing,
|
||||
canManage: api.canManage,
|
||||
isOwner: api.isOwner,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ export interface ApiRepo {
|
||||
htmlUrl: string | null // Gitea 웹 UI URL (미연동 시 null)
|
||||
giteaMissing: boolean // Gitea 조직에서 사라진 저장소 표시(동기화로 갱신)
|
||||
updatedAt: string
|
||||
// 현재 사용자 권한 — 단건 조회에서만 내려옴(목록은 미포함)
|
||||
canManage?: boolean // admin (설정 변경/탭 노출)
|
||||
isOwner?: boolean // 소유자 (저장소 삭제)
|
||||
}
|
||||
|
||||
// 저장소 생성 요청
|
||||
|
||||
Reference in New Issue
Block a user