refactor: 저장소(Repo)·Gitea 연동 기능 완전 제거 — 프로젝트 단독 구조

복잡도를 줄이기 위해 저장소 연동 기능을 전부 제거하고 프로젝트를 단독 단위로 운영한다.

백엔드:
- modules/repo, modules/gitea 모듈 전체 삭제(엔티티/서비스/컨트롤러/DTO/역방향 동기화)
- app.module 에서 RepoModule 제거
- ProjectService: repoCount 집계·repoRepo·"미분류" 시스템 프로젝트(ensureUnassigned/
  onApplicationBootstrap)·isSystem 게이트 전부 제거. ProjectResponse 에서 repoCount/isSystem 제거
- Project 엔티티 is_system 컬럼 제거(dev synchronize 가 DROP)
- ProjectModule forFeature 에서 Repo 제거
- Activity 타입 repo.linked/unlinked 제거
- env example 에서 Gitea/REPO_IMPORT 섹션 제거

프론트:
- ProjectReposPage, useRepo 삭제. ProjectTabs 의 '저장소' 탭·repoCount prop 제거
- 4개 페이지의 :repo-count 제거, 라우트 /projects/:id/repos 제거
- types/project·project.store·mock 에서 repoCount/isSystem·Repo 뷰 제거
- ProjectListPage 저장소수/시스템 배지·gitea CSS 제거, activity 의 repo.* 케이스 제거
- types/repo 는 공용 ApiMember 만 유지
- 잔여 '저장소'/Gitea 사용자 문구를 프로젝트로 정리

검증: 백엔드 build/lint 0·8 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-21 01:44:15 +09:00
parent 861c8efa6b
commit ef1757181d
34 changed files with 20 additions and 2369 deletions
+3 -3
View File
@@ -55,9 +55,9 @@ function goSocial(provider: 'google' | 'kakao') {
// 브랜드 패널 강조 포인트
const points = [
'저장소 · 업무 · 담당자로 이어지는 명확한 구조',
'프로젝트 · 업무 · 담당자로 이어지는 명확한 구조',
'체크리스트와 마감기한으로 진행 관리',
'Gitea 연동으로 안전하게',
'역할 기반 권한으로 안전하게 관리',
]
</script>
@@ -76,7 +76,7 @@ const points = [
지시부터 완료까지,<br>팀의 업무 흐름을 하나로
</div>
<div class="bp-sub">
저장소 단위로 업무를 정리하고, 담당자에게 명확하게 지시하세요. 진행 상황은 한눈에
프로젝트 단위로 업무를 정리하고, 담당자에게 명확하게 지시하세요. 진행 상황은 한눈에
추적됩니다.
</div>
<div class="bp-points">
+1 -1
View File
@@ -111,7 +111,7 @@ function loadMore() {
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
모든 저장소
모든 프로젝트
<svg
viewBox="0 0 24 24"
fill="none"
@@ -111,7 +111,6 @@ const ActIcon = defineComponent({
active="activity"
:task-count="repo.totalCount"
:member-count="memberCount"
:repo-count="repo.repoCount"
:can-manage="repo.canManage"
/>
@@ -51,8 +51,7 @@ async function createProject() {
<h1> 프로젝트 만들기</h1>
</div>
<p class="lede">
프로젝트를 만들고 업무를 작성해 담당자에게 전달하세요. Gitea 저장소는 프로젝트 생성
연동할 있습니다.
프로젝트를 만들고 업무를 작성해 담당자에게 전달하세요.
</p>
<form
@@ -157,7 +157,6 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
active="tasks"
:task-count="counts.all"
:member-count="memberCount"
:repo-count="repo.repoCount"
:can-manage="repo.canManage"
/>
@@ -426,10 +425,6 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
.crumb {
padding: 1.125rem 0 0.75rem;
}
.gitea-bar-spacing {
margin-top: 0.75rem;
}
/* 진행률 스트립 (ProjectHeader 의 extra 슬롯에 주입 — 부모 스코프 스타일 적용) */
.prog-strip {
display: flex;
+1 -61
View File
@@ -36,7 +36,7 @@ const filteredRepos = computed(() =>
<span class="count-pill">{{ total }}</span>
</div>
<p class="lede">
프로젝트를 열어 업무를 작성하고 담당자에게 전달하세요. 저장소는 프로젝트에 연동할 있습니다.
프로젝트를 열어 업무를 작성하고 담당자에게 전달하세요.
</p>
<!-- 툴바 -->
@@ -119,32 +119,11 @@ const filteredRepos = computed(() =>
<div class="repo-main">
<div class="repo-title">
<span class="repo-name">{{ repo.name }}</span>
<span
v-if="repo.isSystem"
class="repo-slug"
>시스템</span>
</div>
<div class="repo-desc">
{{ repo.desc }}
</div>
<div class="repo-meta">
<span
v-if="repo.repoCount > 0"
class="mi"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
저장소 {{ repo.repoCount }}
</span>
<span class="mi avstack">
<span
v-for="m in repo.members"
@@ -231,25 +210,6 @@ const filteredRepos = computed(() =>
letter-spacing: -0.025rem;
white-space: nowrap;
}
.gitea-badge {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
color: #1b7a3d;
background: var(--green-weak);
border: 1px solid var(--green-border);
padding: 0.25rem 0.625rem;
border-radius: 20px;
line-height: 1;
white-space: nowrap;
}
.gitea-badge svg {
width: 0.8125rem;
height: 0.8125rem;
}
.lede {
color: var(--text-2);
font-size: 0.844rem;
@@ -383,24 +343,4 @@ const filteredRepos = computed(() =>
color: var(--text-3);
font-size: 0.844rem;
}
/* 원격(Gitea) 없음 배지 */
.missing-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
color: var(--red);
background: var(--red-weak);
border: 1px solid var(--red-border);
padding: 0.125rem 0.4375rem;
border-radius: 20px;
line-height: 1.4;
white-space: nowrap;
}
.missing-badge svg {
width: 0.75rem;
height: 0.75rem;
}
</style>
@@ -232,7 +232,6 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
active="members"
:task-count="repo.totalCount"
:member-count="counts.all"
:repo-count="repo.repoCount"
:can-manage="repo.canManage"
/>
@@ -1,723 +0,0 @@
<script setup lang="ts">
// 프로젝트 연동 저장소 — 연동된 Gitea 저장소 목록 + 저장소 연동(생성) + 연동 해제
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import ProjectHeader from '@/components/ProjectHeader.vue'
import ProjectTabs from '@/components/ProjectTabs.vue'
import { useProjectStore } from '@/stores/project.store'
import { useRepo } from '@/composables/useRepo'
import type { Project } from '@/mock/relay.mock'
import type { ApiRepo, RepoCreateMeta } from '@/types/repo'
const route = useRoute()
const projectId = computed(() => String(route.params.projectId))
const projectStore = useProjectStore()
const repoApi = useRepo()
// 프로젝트 헤더/탭 — API 연동
const project = ref<Project | null>(null)
async function loadProject() {
try {
project.value = await projectStore.fetchOne(projectId.value)
} catch {
project.value = null // 404/403 은 인터셉터가 알림 처리
}
}
const memberCount = computed(() =>
project.value ? project.value.members.length + project.value.moreCount : 0,
)
const canManage = computed(() => project.value?.canManage ?? false)
// 연동 저장소 목록
const repos = ref<ApiRepo[]>([])
const loading = ref(false)
async function loadRepos() {
loading.value = true
try {
const res = await repoApi.list(projectId.value)
repos.value = res.items
} catch {
// 인터셉터가 알림 처리
} finally {
loading.value = false
}
}
watch(
projectId,
() => {
loadProject()
loadRepos()
},
{ immediate: true },
)
// --- 저장소 연동(생성) 폼 ---
const showForm = ref(false)
const meta = ref<RepoCreateMeta | null>(null)
const slug = ref('')
const name = ref('')
const description = ref('')
const useTemplate = ref(false)
const submitting = ref(false)
// slug 가용성 — null(미확인) / true / false
const slugAvailable = ref<boolean | null>(null)
const slugChecking = ref(false)
let slugTimer: ReturnType<typeof setTimeout> | null = null
const slugValid = computed(() => /^[a-z0-9-_]+$/.test(slug.value.trim()))
// 폼 열기 — 메타(소유자/템플릿) 1회 로드
async function openForm() {
showForm.value = true
if (!meta.value) {
try {
meta.value = await repoApi.getCreateMeta(projectId.value)
} catch {
// 인터셉터가 알림 처리
}
}
}
function closeForm() {
showForm.value = false
slug.value = ''
name.value = ''
description.value = ''
useTemplate.value = false
slugAvailable.value = null
}
// slug 입력 디바운스 → 가용성 체크
watch(slug, () => {
slugAvailable.value = null
if (slugTimer) clearTimeout(slugTimer)
const value = slug.value.trim()
if (!value || !slugValid.value) return
slugChecking.value = true
slugTimer = setTimeout(async () => {
try {
slugAvailable.value = await repoApi.checkName(projectId.value, value)
} catch {
slugAvailable.value = null
} finally {
slugChecking.value = false
}
}, 300)
})
onBeforeUnmount(() => {
if (slugTimer) clearTimeout(slugTimer)
})
const canSubmit = computed(
() =>
!submitting.value &&
slugValid.value &&
slugAvailable.value === true &&
name.value.trim() !== '' &&
description.value.trim() !== '',
)
// 저장소 연동 — 성공 시 목록 갱신 + 폼 닫기
async function linkRepo() {
if (!canSubmit.value) return
submitting.value = true
try {
await repoApi.create(projectId.value, {
slug: slug.value.trim(),
name: name.value.trim(),
description: description.value.trim(),
...(meta.value?.template.available ? { useTemplate: useTemplate.value } : {}),
})
closeForm()
await Promise.all([loadRepos(), loadProject()])
} catch {
// 인터셉터가 알림 처리
} finally {
submitting.value = false
}
}
// 연동 해제 — 확인 후 삭제, 목록 갱신
async function unlinkRepo(repo: ApiRepo) {
const ok = window.confirm(
`저장소 ${repo.name}’의 연동을 해제합니다.\nGitea 저장소도 함께 삭제됩니다. 계속할까요?`,
)
if (!ok) return
try {
await repoApi.remove(projectId.value, repo.id)
await Promise.all([loadRepos(), loadProject()])
} catch {
// 인터셉터가 알림 처리
}
}
</script>
<template>
<AppShell>
<div
v-if="project"
class="page"
>
<div class="crumb">
<RouterLink
to="/projects"
class="lnk"
>
프로젝트
</RouterLink>
<span class="sep">/</span>
<RouterLink
:to="`/projects/${project.id}`"
class="lnk"
>
{{ project.name }}
</RouterLink>
<span class="sep">/</span>
<b>저장소</b>
</div>
<ProjectHeader
:repo="project"
:meta-text="`연동 저장소 ${project.repoCount}`"
:members="project.members"
:more-count="project.moreCount"
/>
<ProjectTabs
:project-id="project.id"
active="repos"
:task-count="project.totalCount"
:member-count="memberCount"
:repo-count="project.repoCount"
:can-manage="project.canManage"
/>
<!-- 안내 + 연동 버튼 -->
<div class="repos-head">
<div class="tab-note">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle
cx="12"
cy="12"
r="10"
/><path d="M12 16v-4M12 8h.01" />
</svg>
프로젝트에 코드 저장소를 연동하면 업무를 코드 변경과 연결해 관리할 있습니다. 프로젝트에 여러 저장소를 연동할 있습니다.
</div>
<button
v-if="canManage && !showForm"
class="btn primary"
type="button"
@click="openForm"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14M12 5v14" />
</svg>
저장소 연동
</button>
</div>
<!-- 연동(생성) -->
<form
v-if="showForm"
class="card form"
@submit.prevent="linkRepo"
>
<div class="fblock">
<div class="flabel">
<span class="req">*</span> 표시 제목
</div>
<div class="fhint">
목록과 화면에 표시되는 이름입니다.
</div>
<input
v-model="name"
type="text"
class="tinput"
placeholder="예: 3분기 신제품 런칭 캠페인"
maxlength="40"
>
</div>
<div class="fblock">
<div class="flabel">
<span class="req">*</span> 저장소 이름(slug)
</div>
<div class="fhint">
영문 소문자·숫자와 하이픈(-)·밑줄(_) 사용. {{ meta?.owner ? `${meta.owner}/` : '' }}이름 형태로 생성됩니다.
</div>
<input
v-model="slug"
type="text"
class="tinput"
placeholder="예: q3-launch-campaign"
maxlength="60"
autocomplete="off"
>
<div
v-if="slug.trim() && !slugValid"
class="fstatus bad"
>
사용할 없는 형식입니다.
</div>
<div
v-else-if="slugChecking"
class="fstatus"
>
확인
</div>
<div
v-else-if="slugAvailable === true"
class="fstatus ok"
>
사용 가능한 이름입니다.
</div>
<div
v-else-if="slugAvailable === false"
class="fstatus bad"
>
이미 사용 중인 이름입니다.
</div>
</div>
<div class="fblock">
<div class="flabel">
<span class="req">*</span> 설명
</div>
<textarea
v-model="description"
class="tinput tarea"
maxlength="300"
placeholder="저장소의 목적을 간단히 적어주세요."
/>
</div>
<!-- 템플릿 -->
<div class="fblock">
<div class="flabel">
템플릿
</div>
<div class="fhint">
템플릿을 사용하면 해당 저장소의 폴더 구조·파일이 저장소에 그대로 복제됩니다.
(기본 브랜치는 템플릿을 따릅니다)
</div>
<div class="select-wrap">
<select v-model="useTemplate">
<option :value="false">
없음 ( 저장소)
</option>
<option
v-if="meta?.template.available"
:value="true"
>
{{ meta.template.slug }} 템플릿
</option>
</select>
<span class="caret">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</span>
</div>
</div>
<div class="form-footer">
<button
class="btn ghost"
type="button"
@click="closeForm"
>
취소
</button>
<button
class="btn primary"
type="submit"
:disabled="!canSubmit"
>
{{ submitting ? '연동 중…' : '연동' }}
</button>
</div>
</form>
<!-- 연동 저장소 목록 연동 추가 폼이 열려 있을 숨김 -->
<div
v-if="!showForm"
class="list"
>
<div
v-for="repo in repos"
:key="repo.id"
class="repo-row"
>
<div class="repo-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
</div>
<div class="repo-main">
<div class="repo-title">
<span class="repo-name">{{ repo.name }}</span>
<span
v-if="repo.giteaMissing"
class="missing"
>Gitea 연결 끊김</span>
</div>
<div class="repo-sub">
<span class="slug">{{ repo.slug }}</span>
<span class="dot">·</span>
<span class="branch">{{ repo.branch }}</span>
</div>
</div>
<div class="repo-actions">
<a
v-if="repo.htmlUrl"
:href="repo.htmlUrl"
target="_blank"
rel="noopener noreferrer"
class="btn ghost sm"
>
Gitea 열기
</a>
<button
v-if="canManage"
class="btn danger sm"
type="button"
@click="unlinkRepo(repo)"
>
연동 해제
</button>
</div>
</div>
<div
v-if="loading && repos.length === 0"
class="list-empty"
>
불러오는
</div>
<div
v-else-if="repos.length === 0"
class="list-empty"
>
아직 연동된 저장소가 없습니다.{{ canManage ? ' ‘저장소 연동’으로 추가해 보세요.' : '' }}
</div>
</div>
</div>
<!-- 프로젝트를 찾지 못한 경우 -->
<div
v-else
class="page"
>
<p class="not-found-msg">
요청하신 프로젝트를 찾을 없습니다.
</p>
</div>
</AppShell>
</template>
<style scoped>
.crumb {
padding: 1.125rem 0 0.75rem;
}
.repos-head {
display: flex;
align-items: center;
gap: 1rem;
margin: 1.125rem 0 1rem;
}
/* 멤버 탭 가이드(.tab-note)와 동일한 형식 */
.tab-note {
flex: 1;
font-size: 0.781rem;
color: var(--text-3);
display: flex;
align-items: center;
gap: 0.4375rem;
}
.tab-note svg {
width: 0.875rem;
height: 0.875rem;
color: var(--text-3);
flex-shrink: 0;
}
.btn {
height: 2.25rem;
padding: 0 1rem;
border-radius: var(--radius);
font-size: 0.844rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.375rem;
text-decoration: none;
white-space: nowrap;
}
.btn.sm {
height: 1.875rem;
padding: 0 0.75rem;
font-size: 0.781rem;
}
.btn.primary {
border: none;
background: var(--accent);
color: #fff;
}
.btn.primary:hover {
background: var(--accent-hover);
}
.btn.primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn.ghost {
border: 1px solid var(--border-strong);
background: #fff;
color: var(--text-2);
}
.btn.danger {
border: 1px solid var(--red-border);
background: #fff;
color: var(--red);
}
.btn.danger:hover {
background: var(--red-weak);
}
/* 연동 폼 */
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
}
.form {
margin-bottom: 1.125rem;
}
.fblock {
padding: 1.125rem 1.5rem;
border-top: 1px solid var(--border);
}
.fblock:first-child {
border-top: none;
}
.flabel {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
margin-bottom: 0.4375rem;
display: flex;
align-items: center;
gap: 0.375rem;
}
.flabel .req {
color: var(--accent);
font-weight: 700;
}
.fhint {
font-size: 0.781rem;
color: var(--text-3);
margin-bottom: 0.5625rem;
line-height: 1.5;
}
.tinput {
width: 100%;
height: 2.5rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0 0.75rem;
font-family: inherit;
font-size: 0.875rem;
color: var(--text);
background: #fff;
}
.tinput:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
textarea.tarea {
min-height: 4.5rem;
padding: 0.625rem 0.75rem;
line-height: 1.6;
resize: vertical;
height: auto;
}
.fstatus {
font-size: 0.781rem;
margin-top: 0.4375rem;
color: var(--text-3);
}
.fstatus.ok {
color: #1b7a3d;
}
.fstatus.bad {
color: var(--red);
}
/* 템플릿 select */
.select-wrap {
position: relative;
}
.select-wrap select {
appearance: none;
-webkit-appearance: none;
width: 100%;
height: 2.5rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0 2.375rem 0 0.75rem;
font-family: inherit;
font-size: 0.875rem;
color: var(--text);
background: #fff;
cursor: pointer;
}
.select-wrap select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.select-wrap .caret {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-3);
pointer-events: none;
display: grid;
place-items: center;
}
.select-wrap .caret svg {
width: 1rem;
height: 1rem;
}
.form-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5625rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
background: #fafbfc;
border-radius: 0 0 0.625rem 0.625rem;
}
/* 저장소 목록 */
.list {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
}
.repo-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--border);
}
.repo-row:first-child {
border-top: none;
}
.repo-ico {
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5625rem;
background: var(--accent-weak);
color: var(--accent);
display: grid;
place-items: center;
flex-shrink: 0;
}
.repo-ico svg {
width: 1.1875rem;
height: 1.1875rem;
}
.repo-main {
flex: 1;
min-width: 0;
}
.repo-title {
display: flex;
align-items: center;
gap: 0.5625rem;
flex-wrap: wrap;
}
.repo-name {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
}
.missing {
font-size: 0.6875rem;
font-weight: 600;
color: var(--red);
background: var(--red-weak);
border: 1px solid var(--red-border);
border-radius: 20px;
padding: 0.125rem 0.5rem;
}
.repo-sub {
display: flex;
align-items: center;
gap: 0.4375rem;
margin-top: 0.3125rem;
font-size: 0.781rem;
color: var(--text-3);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.repo-sub .dot {
font-family: inherit;
}
.repo-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.list-empty {
padding: 2.5rem 1.25rem;
text-align: center;
color: var(--text-3);
font-size: 0.844rem;
}
.not-found-msg {
padding: 3rem 0;
text-align: center;
color: var(--text-2);
}
</style>
@@ -112,7 +112,6 @@ async function removeRepo() {
active="settings"
:task-count="repo.totalCount"
:member-count="memberCount"
:repo-count="repo.repoCount"
:can-manage="repo.canManage"
/>
@@ -320,10 +319,6 @@ textarea.tarea {
height: auto;
}
.gitea-bar-spacing {
margin-top: 0.6875rem;
}
.readonly {
display: flex;
align-items: center;