Compare commits

..

5 Commits

Author SHA1 Message Date
e48aae1f04 QA: SPRINT-002 review iteration 2 — PASSED 2026-04-04 11:44:54 +09:00
0b23deca48 fix(sprint-002): CR-001 GiteaService graceful fallback, CR-002 DTO validation
CR-001: GiteaService constructor throw 제거
- 토큰 미설정 시 client=null + warn 로그로 graceful fallback
- isAvailable() 가드 추가, 모든 메서드 빈 배열/null 반환

CR-002: DTO class-validator 적용
- CreateSprintDto: @IsNumber @Min(1), @IsString
- UpdateTaskDto: @IsOptional @IsString @IsIn(VALID_STATUSES)
- main.ts: ValidationPipe({ whitelist, forbidNonWhitelisted, transform })
2026-04-04 11:43:35 +09:00
e0041172a7 QA: SPRINT-002 review iteration 1 — FAILED (blocking 2) 2026-04-04 11:42:05 +09:00
52732e88b9 feat(sprint-002): projects + task ledger + activity feed
Sprint 001 non-blocking 해결:
- N6: npm audit high 패키지 업데이트 (@nestjs/config, @nestjs/cli)
- S1: CORS origin 화이트리스트 (CORS_ORIGINS env)
- S3: DATABASE_URL 미설정 시 즉시 throw
- N2: SSH_KEY_PATH 미설정 시 즉시 throw (하드코딩 fallback 제거)
- N3: API 응답에서 내부 IP 제거
- N7: SshService 에러 메시지 마스킹 (IP 노출 차단)
- N4: theme.ts 색상 SisterCard/StatusBadge에 실제 적용
- N5: Sidebar-MainContent margin 연동 (SidebarContext)
- N9: LayoutShell Client 컴포넌트로 분리

Sprint 002 본문:
- TASK-004: GiteaService (org repo 목록, PR 조회)
- TASK-004: GET /api/projects → Gitea + DB 병합
- TASK-004: GET /api/projects/:id → 상세 + open PRs
- TASK-005: GET /api/projects/:id/tasks → Sprint/Task Ledger
- TASK-005: POST /api/projects/:id/sprints → Sprint 생성
- TASK-005: PATCH /api/tasks/:id → Task 상태 업데이트 + ActivityLog
- TASK-006: GET /api/activity, /api/projects/:id/activity
- TASK-007: /projects/[id] 상세 페이지 (SprintAccordion + TaskTable + 활동탭)
- TASK-007: 메인 대시보드에 프로젝트 섹션 + 활동 피드 추가
- 테스트 11/11 pass
2026-04-04 11:34:04 +09:00
nabomhalang
1c8d015810 Merge feature/sprint-001-setup into main (SPRINT-001) 2026-04-04 02:15:28 +00:00
40 changed files with 3089 additions and 112 deletions

View File

@@ -0,0 +1,92 @@
# SPRINT-002 QA Review — Iteration 1
- **검증일시:** 2026-04-04 02:41 UTC
- **검증자:** 다랑이 (Evaluator)
- **결과:** ❌ FAILED (blocking 2건)
## 검증 항목
| 항목 | 결과 |
|------|------|
| npm install (backend) | ✅ |
| npm install (frontend) | ✅ |
| prisma generate | ✅ |
| npm test (backend) | ✅ 11/11 pass |
| npm run build (backend) | ✅ |
| npm run build (frontend) | ✅ |
| FE SSR 렌더링 확인 | ✅ |
| /projects/[id] 동적 라우트 | ✅ |
## Sprint 001 non-blocking 수정 확인
| 항목 | 상태 |
|------|------|
| CORS 화이트리스트 | ✅ 해결 |
| DATABASE_URL throw | ✅ 해결 |
| SSH_KEY_PATH throw | ✅ 해결 |
| SSH 에러 마스킹 | ✅ 해결 |
| LayoutShell 분리 | ✅ 해결 |
| Sidebar↔MainContent 연동 | ✅ 해결 |
| API 응답 IP 제거 | ✅ 해결 |
| console.log → Logger | ✅ 해결 |
| theme.ts 적용 | ✅ 직접 import 패턴 (OK) |
| npm audit 7개 | ❌ 미해결 (devDep chain) |
## Sprint 002 설계 대조
| 설계 항목 | 일치 |
|-----------|------|
| GiteaService (org repos, repo detail, PRs) | ✅ |
| GET /api/projects | ✅ |
| GET /api/projects/:id | ✅ |
| GET /api/projects/:id/tasks | ✅ |
| POST /api/projects/:id/sprints | ✅ |
| PATCH /api/tasks/:id | ✅ |
| GET /api/activity (limit, offset) | ✅ |
| GET /api/projects/:id/activity | ✅ |
| SprintAccordion 컴포넌트 | ✅ |
| TaskTable + 필터링 | ✅ |
| TabNav (Sprints/Tasks/Activity) | ✅ |
| ProgressBar | ✅ |
| TaskBadge (7개 상태) | ✅ |
| ActivityFeed 타임라인 | ✅ |
| ProjectCard + 링크 | ✅ |
| 메인 대시보드 프로젝트 섹션 | ✅ |
| 메인 대시보드 활동 피드 | ✅ |
## 🔴 Blocking 이슈
### CR-001: GiteaService GITEA_BASE_URL/GITEA_TOKEN 미설정 시 앱 부팅 실패
- **파일:** `backend/src/gitea/gitea.service.ts`
- **문제:** constructor에서 `throw new Error()` → DI 실패 → 앱 전체 부팅 실패
- **설계 불일치:** 핸드오프에서 "Gitea API 토큰 없으면 graceful fallback으로 DB only"라고 명시했으나 코드는 throw
- **수정:** Optional 패턴으로 변경. client를 null로 두고 메서드에서 빈 배열/null 반환
### CR-002: CreateSprintDto/UpdateTaskDto에 class-validator 미적용
- **파일:** `backend/src/tasks/tasks.service.ts`
- **문제:** plain class DTO — ValidationPipe 없이 아무 값이나 DB 직행
- **위험:** status에 임의 문자열, number에 음수, 예상 외 필드 주입 가능
- **수정:** class-validator 데코레이터 추가 + main.ts에 ValidationPipe 적용
## Non-blocking 이슈
### 코드 품질 (code-reviewer)
| ID | 이슈 |
|----|------|
| CR-003 | iteration 증가 로직 — 정확하지만 메서드 분리로 가독성 개선 권장 |
| CR-004 | limit/offset Query 파라미터 NaN 미처리 — ParseIntPipe 적용 권장 |
| CR-006 | giteaRepos 키 매칭 — giteaId null 방어 필요 |
| CR-007 | ProjectDetailPage useState<any> 3곳 — 인터페이스 정의 권장 |
| CR-008 | CORS 에러에 origin 값 노출 — generic 에러 반환 권장 |
| CR-009 | npm audit 7개 — devDependency 확인 후 prod 0이면 수용 가능 |
### 보안 감사 (security-auditor)
| ID | 이슈 | 심각도 |
|----|------|--------|
| SA-005 | API 인증 없음 (전체) — Sprint-003 필수 | Medium |
| SA-006 | PATCH /tasks/:id 인증 없음 (write) | High |
| SA-007 | POST /projects/:id/sprints 인증 없음 (write) | High |
| SA-008 | limit/offset 클램핑 없음 (대량 조회 가능) | Medium |
| SA-012 | Helmet 미적용 | Medium |
| SA-013 | Rate limiting 없음 | Medium |
| SA-014 | GiteaService 에러 시 Authorization 헤더 노출 가능 | Medium |

View File

@@ -0,0 +1,29 @@
# SPRINT-002 QA Review — Iteration 2
- **검증일시:** 2026-04-04 02:44 UTC
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## Blocking 수정 확인
| ID | 이슈 | 수정 확인 |
|----|------|----------|
| CR-001 | GiteaService throw → graceful fallback | ✅ client=null + isAvailable() 가드 + 빈 배열/null 반환 |
| CR-002 | DTO class-validator + ValidationPipe | ✅ @IsNumber/@Min/@IsString/@IsOptional/@IsIn 적용 + main.ts ValidationPipe(whitelist, forbidNonWhitelisted, transform) |
## 검증 항목
| 항목 | 결과 |
|------|------|
| git fetch + reset | ✅ |
| npm install | ✅ |
| prisma generate | ✅ |
| npm test (backend) | ✅ 11/11 pass |
| npm run build (backend) | ✅ |
| npm run build (frontend) | ✅ |
## 판정 근거
- Iteration 1의 blocking 2건 모두 정확하게 수정됨
- 테스트/빌드 전부 통과
- 설계 문서와 코드 일치 (Iteration 1에서 이미 확인)
- Non-blocking 이슈 14건은 Sprint-003에서 해결 권고

View File

@@ -15,6 +15,9 @@
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@prisma/adapter-mariadb": "^7.6.0", "@prisma/adapter-mariadb": "^7.6.0",
"@prisma/client": "^7.6.0", "@prisma/client": "^7.6.0",
"axios": "^1.14.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"dotenv": "^17.4.0", "dotenv": "^17.4.0",
"mariadb": "^3.5.2", "mariadb": "^3.5.2",
"node-ssh": "^13.2.1", "node-ssh": "^13.2.1",
@@ -25,7 +28,7 @@
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.17",
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
@@ -3263,6 +3266,12 @@
"@types/superagent": "^8.1.0" "@types/superagent": "^8.1.0"
} }
}, },
"node_modules/@types/validator": {
"version": "13.15.10",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT"
},
"node_modules/@types/yargs": { "node_modules/@types/yargs": {
"version": "17.0.35", "version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@@ -4280,7 +4289,6 @@
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/aws-ssl-profiles": { "node_modules/aws-ssl-profiles": {
@@ -4292,6 +4300,17 @@
"node": ">= 6.0.0" "node": ">= 6.0.0"
} }
}, },
"node_modules/axios": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/babel-jest": { "node_modules/babel-jest": {
"version": "30.3.0", "version": "30.3.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz",
@@ -4843,6 +4862,23 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/class-transformer": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT"
},
"node_modules/class-validator": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz",
"integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==",
"license": "MIT",
"dependencies": {
"@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1",
"validator": "^13.15.22"
}
},
"node_modules/cli-cursor": { "node_modules/cli-cursor": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
@@ -4980,7 +5016,6 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"delayed-stream": "~1.0.0" "delayed-stream": "~1.0.0"
@@ -5281,7 +5316,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.4.0" "node": ">=0.4.0"
@@ -5540,7 +5574,6 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -6141,6 +6174,26 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": { "node_modules/foreground-child": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@@ -6189,7 +6242,6 @@
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
@@ -6206,7 +6258,6 @@
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
@@ -6216,7 +6267,6 @@
"version": "2.1.35", "version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"mime-db": "1.52.0" "mime-db": "1.52.0"
@@ -6604,7 +6654,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"has-symbols": "^1.0.3" "has-symbols": "^1.0.3"
@@ -7929,6 +7978,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/libphonenumber-js": {
"version": "1.12.41",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.41.tgz",
"integrity": "sha512-lsmMmGXBxXIK/VMLEj0kL6MtUs1kBGj1nTCzi6zgQoG1DEwqwt2DQyHxcLykceIxAnfE3hya7NuIh6PpC6S3fA==",
"license": "MIT"
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -9113,6 +9168,15 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -10727,6 +10791,15 @@
} }
} }
}, },
"node_modules/validator": {
"version": "13.15.35",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
"integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

@@ -27,6 +27,9 @@
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@prisma/adapter-mariadb": "^7.6.0", "@prisma/adapter-mariadb": "^7.6.0",
"@prisma/client": "^7.6.0", "@prisma/client": "^7.6.0",
"axios": "^1.14.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"dotenv": "^17.4.0", "dotenv": "^17.4.0",
"mariadb": "^3.5.2", "mariadb": "^3.5.2",
"node-ssh": "^13.2.1", "node-ssh": "^13.2.1",
@@ -40,7 +43,7 @@
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.17",
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",

View File

@@ -0,0 +1,31 @@
import { Controller, Get, Param, Query, ParseIntPipe } from '@nestjs/common';
import { ActivityService } from './activity.service';
@Controller('api')
export class ActivityController {
constructor(private readonly activityService: ActivityService) {}
@Get('activity')
getFeed(
@Query('limit') limit?: string,
@Query('offset') offset?: string,
) {
return this.activityService.getFeed(
limit ? parseInt(limit, 10) : 50,
offset ? parseInt(offset, 10) : 0,
);
}
@Get('projects/:id/activity')
getProjectFeed(
@Param('id', ParseIntPipe) id: number,
@Query('limit') limit?: string,
@Query('offset') offset?: string,
) {
return this.activityService.getProjectFeed(
id,
limit ? parseInt(limit, 10) : 30,
offset ? parseInt(offset, 10) : 0,
);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ActivityController } from './activity.controller';
import { ActivityService } from './activity.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [ActivityController],
providers: [ActivityService],
exports: [ActivityService],
})
export class ActivityModule {}

View File

@@ -0,0 +1,57 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ActivityService } from './activity.service';
import { PrismaService } from '../prisma/prisma.service';
describe('ActivityService', () => {
let service: ActivityService;
const mockPrisma = {
activityLog: {
create: jest.fn().mockResolvedValue({ id: 1, action: 'test', createdAt: new Date() }),
findMany: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ActivityService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<ActivityService>(ActivityService);
jest.clearAllMocks();
});
it('log: activityLog.create 호출', async () => {
await service.log({ action: 'test_action', detail: 'detail' });
expect(mockPrisma.activityLog.create).toHaveBeenCalledWith({
data: { action: 'test_action', detail: 'detail' },
});
});
it('getFeed: items + total 반환', async () => {
mockPrisma.activityLog.findMany.mockResolvedValue([{ id: 1 }]);
mockPrisma.activityLog.count.mockResolvedValue(1);
const result = await service.getFeed(10, 0);
expect(result.items).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.limit).toBe(10);
expect(result.offset).toBe(0);
});
it('getProjectFeed: projectId 필터 적용', async () => {
mockPrisma.activityLog.findMany.mockResolvedValue([]);
mockPrisma.activityLog.count.mockResolvedValue(0);
await service.getProjectFeed(42);
expect(mockPrisma.activityLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { projectId: 42 } }),
);
});
});

View File

@@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export interface LogActivityDto {
sisterId?: number;
projectId?: number;
action: string;
detail?: string;
}
@Injectable()
export class ActivityService {
constructor(private readonly prisma: PrismaService) {}
async log(dto: LogActivityDto) {
return this.prisma.activityLog.create({ data: dto });
}
async getFeed(limit = 50, offset = 0) {
const [items, total] = await Promise.all([
this.prisma.activityLog.findMany({
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
include: {
sister: { select: { name: true } },
project: { select: { name: true } },
},
}),
this.prisma.activityLog.count(),
]);
return { items, total, limit, offset };
}
async getProjectFeed(projectId: number, limit = 30, offset = 0) {
const [items, total] = await Promise.all([
this.prisma.activityLog.findMany({
where: { projectId },
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
include: {
sister: { select: { name: true } },
},
}),
this.prisma.activityLog.count({ where: { projectId } }),
]);
return { items, total, limit, offset };
}
}

View File

@@ -5,6 +5,10 @@ import { AppService } from './app.service';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { SistersModule } from './sisters/sisters.module'; import { SistersModule } from './sisters/sisters.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { GiteaModule } from './gitea/gitea.module';
import { ProjectsModule } from './projects/projects.module';
import { TasksModule } from './tasks/tasks.module';
import { ActivityModule } from './activity/activity.module';
@Module({ @Module({
imports: [ imports: [
@@ -12,6 +16,10 @@ import { HealthModule } from './health/health.module';
PrismaModule, PrismaModule,
SistersModule, SistersModule,
HealthModule, HealthModule,
GiteaModule,
ProjectsModule,
TasksModule,
ActivityModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { GiteaService } from './gitea.service';
@Module({
imports: [ConfigModule],
providers: [GiteaService],
exports: [GiteaService],
})
export class GiteaModule {}

View File

@@ -0,0 +1,90 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
export interface GiteaRepo {
id: number;
name: string;
full_name: string;
description: string;
html_url: string;
default_branch: string;
open_issues_count: number;
open_pr_counter: number;
updated_at: string;
}
export interface GiteaPR {
id: number;
number: number;
title: string;
state: string;
html_url: string;
user: { login: string };
created_at: string;
}
@Injectable()
export class GiteaService {
private readonly logger = new Logger(GiteaService.name);
private readonly client: AxiosInstance;
private readonly org: string;
constructor(private readonly config: ConfigService) {
const baseURL = config.get<string>('GITEA_BASE_URL');
const token = config.get<string>('GITEA_TOKEN');
this.org = config.get<string>('GITEA_ORG') ?? 'hanarang';
if (!baseURL || !token) {
this.logger.warn('GITEA_BASE_URL or GITEA_TOKEN not set — Gitea features disabled');
this.client = null as any;
return;
}
this.client = axios.create({
baseURL: `${baseURL}/api/v1`,
headers: { Authorization: `token ${token}` },
timeout: 10000,
});
}
private isAvailable(): boolean {
return this.client !== null;
}
async getOrgRepos(): Promise<GiteaRepo[]> {
if (!this.isAvailable()) return [];
try {
const { data } = await this.client.get<GiteaRepo[]>(`/orgs/${this.org}/repos`, {
params: { limit: 50 },
});
return data;
} catch (error) {
this.logger.warn('Failed to fetch Gitea repos');
return [];
}
}
async getRepo(repoName: string): Promise<GiteaRepo | null> {
if (!this.isAvailable()) return null;
try {
const { data } = await this.client.get<GiteaRepo>(`/repos/${this.org}/${repoName}`);
return data;
} catch {
return null;
}
}
async getOpenPRs(repoName: string): Promise<GiteaPR[]> {
if (!this.isAvailable()) return [];
try {
const { data } = await this.client.get<GiteaPR[]>(
`/repos/${this.org}/${repoName}/pulls`,
{ params: { state: 'open', limit: 20 } },
);
return data;
} catch {
return [];
}
}
}

View File

@@ -1,10 +1,31 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { Logger, ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.enableCors();
await app.listen(process.env.BACKEND_PORT ?? 3005); const allowedOrigins = (process.env.CORS_ORIGINS ?? 'http://localhost:3004')
console.log(`🚀 Backend running on port ${process.env.BACKEND_PORT ?? 3005}`); .split(',')
.map((o) => o.trim())
.filter(Boolean);
app.enableCors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`CORS: ${origin} not allowed`));
}
},
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
credentials: true,
});
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }));
const port = process.env.BACKEND_PORT ?? 3005;
await app.listen(port);
Logger.log(`🚀 Backend running on port ${port}`, 'Bootstrap');
} }
bootstrap(); bootstrap();

View File

@@ -6,7 +6,10 @@ import { PrismaMariaDb } from '@prisma/adapter-mariadb';
@Injectable() @Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor(config: ConfigService) { constructor(config: ConfigService) {
const dbUrl = config.get<string>('DATABASE_URL') ?? ''; const dbUrl = config.get<string>('DATABASE_URL');
if (!dbUrl) {
throw new Error('DATABASE_URL is not set. Check your .env file.');
}
const adapter = new PrismaMariaDb(dbUrl); const adapter = new PrismaMariaDb(dbUrl);
super({ adapter }); super({ adapter });
} }

View File

@@ -0,0 +1,17 @@
import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common';
import { ProjectsService } from './projects.service';
@Controller('api/projects')
export class ProjectsController {
constructor(private readonly projectsService: ProjectsService) {}
@Get()
getProjects() {
return this.projectsService.getProjects();
}
@Get(':id')
getProjectById(@Param('id', ParseIntPipe) id: number) {
return this.projectsService.getProjectById(id);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ProjectsController } from './projects.controller';
import { ProjectsService } from './projects.service';
import { PrismaModule } from '../prisma/prisma.module';
import { GiteaModule } from '../gitea/gitea.module';
import { ActivityModule } from '../activity/activity.module';
@Module({
imports: [PrismaModule, GiteaModule, ActivityModule],
controllers: [ProjectsController],
providers: [ProjectsService],
})
export class ProjectsModule {}

View File

@@ -0,0 +1,82 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { GiteaService } from '../gitea/gitea.service';
import { ActivityService } from '../activity/activity.service';
@Injectable()
export class ProjectsService {
private readonly logger = new Logger(ProjectsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly gitea: GiteaService,
private readonly activity: ActivityService,
) {}
async getProjects() {
// DB에 등록된 프로젝트 + Gitea 최신 정보 병합
const dbProjects = await this.prisma.project.findMany({
include: { sprints: { include: { tasks: true } } },
orderBy: { updatedAt: 'desc' },
});
let giteaRepos: Record<number, { openPRs: number; updatedAt: string }> = {};
try {
const repos = await this.gitea.getOrgRepos();
for (const repo of repos) {
giteaRepos[repo.id] = {
openPRs: repo.open_pr_counter,
updatedAt: repo.updated_at,
};
}
} catch {
this.logger.warn('Gitea unavailable, using DB data only');
}
return dbProjects.map((p) => {
const totalTasks = p.sprints.flatMap((s) => s.tasks).length;
const doneTasks = p.sprints.flatMap((s) => s.tasks).filter((t) => t.status === 'done').length;
const gitea = giteaRepos[p.giteaId];
return {
id: p.id,
giteaId: p.giteaId,
name: p.name,
repoUrl: p.repoUrl,
description: p.description,
status: p.status,
progress: totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0,
totalTasks,
doneTasks,
openPRs: gitea?.openPRs ?? 0,
updatedAt: gitea?.updatedAt ?? p.updatedAt.toISOString(),
sprintCount: p.sprints.length,
currentSprint: p.sprints.find((s) => s.status === 'in_progress')?.name ?? null,
};
});
}
async getProjectById(id: number) {
const project = await this.prisma.project.findUnique({
where: { id },
include: {
sprints: {
include: { tasks: { orderBy: { taskId: 'asc' } } },
orderBy: { number: 'asc' },
},
},
});
if (!project) throw new NotFoundException(`Project ${id} not found`);
let giteaPRs: ReturnType<typeof Array.prototype.map> = [];
try {
const repoName = project.repoUrl.split('/').pop() ?? '';
giteaPRs = await this.gitea.getOpenPRs(repoName);
} catch {
this.logger.warn(`Gitea PR fetch failed for project ${id}`);
}
return { ...project, openPRs: giteaPRs };
}
}

View File

@@ -35,7 +35,11 @@ describe('SistersService', () => {
{ provide: PrismaService, useValue: mockPrisma }, { provide: PrismaService, useValue: mockPrisma },
{ {
provide: ConfigService, provide: ConfigService,
useValue: { get: jest.fn().mockReturnValue('/home/narang/.ssh/id_rsa') }, useValue: { get: jest.fn().mockImplementation((key: string) => {
if (key === 'SSH_KEY_PATH') return '/home/narang/.ssh/id_rsa';
if (key === 'DATABASE_URL') return 'mysql://test:test@localhost:3306/test';
return undefined;
}) },
}, },
], ],
}).compile(); }).compile();
@@ -54,6 +58,7 @@ describe('SistersService', () => {
expect(result[0].status).toBe('online'); expect(result[0].status).toBe('online');
expect(result[0].name).toBe('harang'); expect(result[0].name).toBe('harang');
expect(result[0].role).toBe('Orchestrator'); expect(result[0].role).toBe('Orchestrator');
expect((result[0] as any).ip).toBeUndefined(); // IP 노출 제거 확인
}); });
it('SSH 실패 시 offline graceful fallback', async () => { it('SSH 실패 시 offline graceful fallback', async () => {

View File

@@ -6,7 +6,6 @@ import { ConfigService } from '@nestjs/config';
export interface SisterStatus { export interface SisterStatus {
id: number; id: number;
name: string; name: string;
ip: string;
user: string; user: string;
lxcId: number; lxcId: number;
role: string; role: string;
@@ -34,7 +33,10 @@ export class SistersService {
async getAllSistersStatus(): Promise<SisterStatus[]> { async getAllSistersStatus(): Promise<SisterStatus[]> {
const sisters = await this.prisma.sisterConfig.findMany(); const sisters = await this.prisma.sisterConfig.findMany();
const sshKeyPath = this.config.get<string>('SSH_KEY_PATH') ?? '/home/narang/.ssh/id_rsa'; const sshKeyPath = this.config.get<string>('SSH_KEY_PATH');
if (!sshKeyPath) {
throw new Error('SSH_KEY_PATH is not set. Check your .env file.');
}
const results = await Promise.allSettled( const results = await Promise.allSettled(
sisters.map((sister) => this.checkSisterStatus(sister, sshKeyPath)), sisters.map((sister) => this.checkSisterStatus(sister, sshKeyPath)),
@@ -49,7 +51,6 @@ export class SistersService {
return { return {
id: sister.id, id: sister.id,
name: sister.name, name: sister.name,
ip: sister.ip,
user: sister.user, user: sister.user,
lxcId: sister.lxcId, lxcId: sister.lxcId,
role: SISTER_ROLES[sister.name] ?? 'Unknown', role: SISTER_ROLES[sister.name] ?? 'Unknown',
@@ -86,7 +87,6 @@ export class SistersService {
return { return {
id: sister.id, id: sister.id,
name: sister.name, name: sister.name,
ip: sister.ip,
user: sister.user, user: sister.user,
lxcId: sister.lxcId, lxcId: sister.lxcId,
role: SISTER_ROLES[sister.name] ?? 'Unknown', role: SISTER_ROLES[sister.name] ?? 'Unknown',
@@ -95,11 +95,10 @@ export class SistersService {
currentTask: null, currentTask: null,
}; };
} catch { } catch {
this.logger.warn(`Failed to check status for ${sister.name} (${sister.ip})`); this.logger.warn(`Failed to check status for ${sister.name}`);
return { return {
id: sister.id, id: sister.id,
name: sister.name, name: sister.name,
ip: sister.ip,
user: sister.user, user: sister.user,
lxcId: sister.lxcId, lxcId: sister.lxcId,
role: SISTER_ROLES[sister.name] ?? 'Unknown', role: SISTER_ROLES[sister.name] ?? 'Unknown',

View File

@@ -24,7 +24,7 @@ export class SshService {
host, host,
username, username,
privateKeyPath, privateKeyPath,
readyTimeout: 5000, readyTimeout: parseInt(process.env.SSH_TIMEOUT_MS ?? '5000', 10),
}); });
const result = await ssh.execCommand(command); const result = await ssh.execCommand(command);
@@ -34,8 +34,11 @@ export class SshService {
code: result.code, code: result.code,
}; };
} catch (error) { } catch (error) {
this.logger.warn(`SSH connection failed to ${host}: ${(error as Error).message}`); // 민감정보 마스킹 — host/user만 노출, 에러 메시지 제한
throw error; const msg = error instanceof Error ? error.message : 'unknown';
const safeMsg = msg.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '[IP]');
this.logger.warn(`SSH failed [${username}@host]: ${safeMsg}`);
throw new Error(`SSH connection failed for ${username}`);
} finally { } finally {
ssh.dispose(); ssh.dispose();
} }

View File

@@ -0,0 +1,28 @@
import { Controller, Get, Post, Patch, Param, Body, ParseIntPipe } from '@nestjs/common';
import { TasksService, CreateSprintDto, UpdateTaskDto } from './tasks.service';
@Controller('api')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Get('projects/:id/tasks')
getTaskLedger(@Param('id', ParseIntPipe) id: number) {
return this.tasksService.getTaskLedger(id);
}
@Post('projects/:id/sprints')
createSprint(
@Param('id', ParseIntPipe) id: number,
@Body() dto: CreateSprintDto,
) {
return this.tasksService.createSprint(id, dto);
}
@Patch('tasks/:id')
updateTask(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateTaskDto,
) {
return this.tasksService.updateTask(id, dto);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
import { PrismaModule } from '../prisma/prisma.module';
import { ActivityModule } from '../activity/activity.module';
@Module({
imports: [PrismaModule, ActivityModule],
controllers: [TasksController],
providers: [TasksService],
})
export class TasksModule {}

View File

@@ -0,0 +1,79 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TasksService } from './tasks.service';
import { PrismaService } from '../prisma/prisma.service';
import { ActivityService } from '../activity/activity.service';
import { NotFoundException } from '@nestjs/common';
describe('TasksService', () => {
let service: TasksService;
const mockTask = {
id: 1, taskId: 'TASK-001', title: 'Test', assignee: 'narang',
status: 'pending', iteration: 0, sprintId: 1,
createdAt: new Date(), updatedAt: new Date(),
};
const mockSprint = {
id: 1, projectId: 1, number: 1, name: 'Sprint 001',
status: 'in_progress', startedAt: null, completedAt: null,
createdAt: new Date(), updatedAt: new Date(),
tasks: [mockTask],
};
const mockPrisma = {
project: {
findUnique: jest.fn(),
},
sprint: {
create: jest.fn(),
findUnique: jest.fn().mockResolvedValue(mockSprint),
},
task: {
findUnique: jest.fn(),
update: jest.fn(),
},
};
const mockActivity = { log: jest.fn().mockResolvedValue({}) };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TasksService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: ActivityService, useValue: mockActivity },
],
}).compile();
service = module.get<TasksService>(TasksService);
jest.clearAllMocks();
});
it('getTaskLedger: project 없으면 NotFoundException', async () => {
mockPrisma.project.findUnique.mockResolvedValue(null);
await expect(service.getTaskLedger(99)).rejects.toThrow(NotFoundException);
});
it('getTaskLedger: sprint + task + progress 반환', async () => {
mockPrisma.project.findUnique.mockResolvedValue({
id: 1,
sprints: [{ ...mockSprint, tasks: [{ ...mockTask, status: 'done' }, mockTask] }],
});
const result = await service.getTaskLedger(1);
expect(result).toHaveLength(1);
expect(result[0].progress).toBe(50);
});
it('updateTask: 상태 변경 시 ActivityLog 기록', async () => {
mockPrisma.task.findUnique.mockResolvedValue({ ...mockTask, status: 'in_progress' });
mockPrisma.task.update.mockResolvedValue({ ...mockTask, status: 'done', sprintId: 1 });
await service.updateTask(1, { status: 'done' });
expect(mockActivity.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'task_updated' }),
);
});
});

View File

@@ -0,0 +1,111 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ActivityService } from '../activity/activity.service';
import { IsNumber, IsString, IsOptional, IsIn, Min } from 'class-validator';
export class CreateSprintDto {
@IsNumber()
@Min(1)
number!: number;
@IsString()
name!: string;
}
const VALID_STATUSES = ['pending', 'in_progress', 'review', 'done', 'failed', 'blocked', 'escalated'] as const;
export class UpdateTaskDto {
@IsOptional()
@IsString()
@IsIn(VALID_STATUSES)
status?: string;
@IsOptional()
@IsString()
assignee?: string;
@IsOptional()
@IsString()
title?: string;
}
@Injectable()
export class TasksService {
constructor(
private readonly prisma: PrismaService,
private readonly activity: ActivityService,
) {}
async getTaskLedger(projectId: number) {
const project = await this.prisma.project.findUnique({
where: { id: projectId },
include: {
sprints: {
include: { tasks: { orderBy: { taskId: 'asc' } } },
orderBy: { number: 'asc' },
},
},
});
if (!project) throw new NotFoundException(`Project ${projectId} not found`);
return project.sprints.map((sprint) => ({
id: sprint.id,
number: sprint.number,
name: sprint.name,
status: sprint.status,
startedAt: sprint.startedAt,
completedAt: sprint.completedAt,
tasks: sprint.tasks,
progress: sprint.tasks.length > 0
? Math.round(sprint.tasks.filter((t) => t.status === 'done').length / sprint.tasks.length * 100)
: 0,
}));
}
async createSprint(projectId: number, dto: CreateSprintDto) {
const project = await this.prisma.project.findUnique({ where: { id: projectId } });
if (!project) throw new NotFoundException(`Project ${projectId} not found`);
const sprint = await this.prisma.sprint.create({
data: { projectId, number: dto.number, name: dto.name },
});
await this.activity.log({
projectId,
action: 'sprint_created',
detail: `Sprint ${dto.number} "${dto.name}" created`,
});
return sprint;
}
async updateTask(taskId: number, dto: UpdateTaskDto) {
const task = await this.prisma.task.findUnique({ where: { id: taskId } });
if (!task) throw new NotFoundException(`Task ${taskId} not found`);
const prevStatus = task.status;
const updated = await this.prisma.task.update({
where: { id: taskId },
data: {
...dto,
iteration: dto.status && dto.status !== prevStatus && prevStatus !== 'pending'
? { increment: 1 }
: undefined,
},
});
const sprint = await this.prisma.sprint.findUnique({
where: { id: updated.sprintId },
});
await this.activity.log({
projectId: sprint?.projectId,
action: 'task_updated',
detail: `Task ${updated.taskId} status: ${prevStatus}${updated.status}`,
});
return updated;
}
}

View File

@@ -1,27 +1,13 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import StyledComponentsRegistry from '@/lib/registry'; import StyledComponentsRegistry from '@/lib/registry';
import GlobalStyle from '@/styles/GlobalStyle'; import GlobalStyle from '@/styles/GlobalStyle';
import Sidebar from '@/components/common/Sidebar'; import LayoutShell from '@/components/common/LayoutShell';
import styled from 'styled-components';
export const metadata: Metadata = { export const metadata: Metadata = {
title: '하나랑 대시보드', title: '하나랑 대시보드',
description: '4자매 멀티에이전트 파이프라인 관제 대시보드', description: '4자매 멀티에이전트 파이프라인 관제 대시보드',
}; };
const LayoutRoot = styled.div`
display: flex;
min-height: 100vh;
background: #0D1117;
`;
const MainContent = styled.main`
flex: 1;
margin-left: 220px;
min-height: 100vh;
transition: margin-left 0.2s ease;
`;
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
@@ -32,10 +18,7 @@ export default function RootLayout({
<body> <body>
<StyledComponentsRegistry> <StyledComponentsRegistry>
<GlobalStyle /> <GlobalStyle />
<LayoutRoot> <LayoutShell>{children}</LayoutShell>
<Sidebar />
<MainContent>{children}</MainContent>
</LayoutRoot>
</StyledComponentsRegistry> </StyledComponentsRegistry>
</body> </body>
</html> </html>

View File

@@ -3,13 +3,15 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import SisterCard from '@/components/dashboard/SisterCard'; import SisterCard from '@/components/dashboard/SisterCard';
import ProjectCard from '@/components/projects/ProjectCard';
import ActivityFeed from '@/components/dashboard/ActivityFeed';
import { theme } from '@/styles/theme';
type Status = 'online' | 'offline' | 'working' | 'unknown'; type Status = 'online' | 'offline' | 'working' | 'unknown';
interface SisterStatus { interface SisterStatus {
id: number; id: number;
name: string; name: string;
ip: string;
user: string; user: string;
lxcId: number; lxcId: number;
role: string; role: string;
@@ -19,10 +21,10 @@ interface SisterStatus {
} }
const MOCK_SISTERS: SisterStatus[] = [ const MOCK_SISTERS: SisterStatus[] = [
{ id: 1, name: 'harang', ip: '10.10.10.112', user: 'harang', lxcId: 104, role: 'Orchestrator', status: 'online', lastSeen: new Date().toISOString(), currentTask: 'Sprint 001 기획 중' }, { id: 1, name: 'harang', user: 'harang', lxcId: 104, role: 'Orchestrator', status: 'online', lastSeen: new Date().toISOString(), currentTask: 'Sprint 002 기획 중' },
{ id: 2, name: 'narang', ip: '10.10.10.216', user: 'narang', lxcId: 105, role: 'Generator', status: 'working', lastSeen: new Date().toISOString(), currentTask: 'SPRINT-001 구현 중' }, { id: 2, name: 'narang', user: 'narang', lxcId: 105, role: 'Generator', status: 'working', lastSeen: new Date().toISOString(), currentTask: 'SPRINT-002 구현 중' },
{ id: 3, name: 'darang', ip: '10.10.10.136', user: 'darang', lxcId: 106, role: 'Evaluator', status: 'online', lastSeen: new Date().toISOString(), currentTask: null }, { id: 3, name: 'darang', user: 'darang', lxcId: 106, role: 'Evaluator', status: 'online', lastSeen: new Date().toISOString(), currentTask: null },
{ id: 4, name: 'erang', ip: '10.10.10.163', user: 'erang', lxcId: 107, role: 'Infra Manager', status: 'online', lastSeen: new Date().toISOString(), currentTask: null }, { id: 4, name: 'erang', user: 'erang', lxcId: 107, role: 'Infra Manager', status: 'online', lastSeen: new Date().toISOString(), currentTask: null },
]; ];
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3005'; const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3005';
@@ -30,7 +32,7 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3005';
const PageWrapper = styled.div` const PageWrapper = styled.div`
padding: 32px; padding: 32px;
min-height: 100vh; min-height: 100vh;
background: #0D1117; background: ${theme.colors.bg};
`; `;
const PageHeader = styled.div` const PageHeader = styled.div`
@@ -40,19 +42,19 @@ const PageHeader = styled.div`
const PageTitle = styled.h1` const PageTitle = styled.h1`
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
color: #E6EDF3; color: ${theme.colors.textPrimary};
margin-bottom: 6px; margin-bottom: 6px;
`; `;
const PageSubtitle = styled.p` const PageSubtitle = styled.p`
font-size: 14px; font-size: 14px;
color: #8B949E; color: ${theme.colors.textSecondary};
`; `;
const SectionTitle = styled.h2` const SectionTitle = styled.h2`
font-size: 14px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #8B949E; color: ${theme.colors.textSecondary};
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
margin-bottom: 16px; margin-bottom: 16px;
@@ -70,7 +72,6 @@ const SistersGrid = styled.div`
@media (max-width: 640px) { @media (max-width: 640px) {
flex-direction: column; flex-direction: column;
> * { flex: 1 1 auto; }
} }
`; `;
@@ -78,60 +79,77 @@ const TwoColumnRow = styled.div`
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 20px; gap: 20px;
margin-bottom: 40px;
@media (max-width: 900px) { @media (max-width: 900px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
`; `;
const PlaceholderCard = styled.div` const ProjectsGrid = styled.div`
background: rgba(22, 27, 34, 0.8); display: grid;
backdrop-filter: blur(12px); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
border: 1px solid rgba(240, 246, 252, 0.1); gap: 16px;
margin-bottom: 40px;
`;
const Card = styled.div`
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 12px; border-radius: 12px;
padding: 24px; padding: 20px;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
color: #8B949E;
font-size: 14px;
`; `;
const ErrorBanner = styled.div` const ErrorBanner = styled.div`
padding: 10px 14px; padding: 10px 14px;
background: rgba(255, 23, 68, 0.08); background: rgba(255,23,68,0.08);
border: 1px solid rgba(255, 23, 68, 0.3); border: 1px solid rgba(255,23,68,0.3);
border-radius: 8px; border-radius: 8px;
font-size: 13px; font-size: 13px;
color: #FF1744; color: ${theme.colors.offline};
margin-bottom: 20px; margin-bottom: 20px;
`; `;
const PlaceholderText = styled.div`
padding: 32px;
text-align: center;
color: ${theme.colors.textSecondary};
font-size: 13px;
`;
export default function DashboardPage() { export default function DashboardPage() {
const [sisters, setSisters] = useState<SisterStatus[]>(MOCK_SISTERS); const [sisters, setSisters] = useState<SisterStatus[]>(MOCK_SISTERS);
const [loading, setLoading] = useState(false); const [projects, setProjects] = useState<any[]>([]);
const [activityItems, setActivityItems] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const fetchSisters = async () => { const fetchAll = async () => {
setLoading(true);
try { try {
const res = await fetch(`${API_URL}/api/sisters`, { cache: 'no-store' }); const [sRes, pRes, aRes] = await Promise.allSettled([
if (!res.ok) throw new Error(`API error ${res.status}`); fetch(`${API_URL}/api/sisters`),
const data = await res.json(); fetch(`${API_URL}/api/projects`),
setSisters(data); fetch(`${API_URL}/api/activity?limit=20`),
]);
if (sRes.status === 'fulfilled' && sRes.value.ok) {
setSisters(await sRes.value.json());
}
if (pRes.status === 'fulfilled' && pRes.value.ok) {
setProjects(await pRes.value.json());
}
if (aRes.status === 'fulfilled' && aRes.value.ok) {
const d = await aRes.value.json();
setActivityItems(d.items ?? []);
}
setError(null); setError(null);
} catch (e) { } catch {
// API 미준비 시 mock 데이터 유지 setError('API 연결 실패 — 일부 데이터는 mock으로 표시 중');
setError('API 연결 실패 — mock 데이터로 표시 중');
} finally {
setLoading(false);
} }
}; };
fetchSisters(); fetchAll();
const interval = setInterval(fetchSisters, 30000); const interval = setInterval(fetchAll, 30000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
@@ -154,21 +172,49 @@ export default function DashboardPage() {
status={sister.status} status={sister.status}
lastSeen={sister.lastSeen} lastSeen={sister.lastSeen}
currentTask={sister.currentTask} currentTask={sister.currentTask}
ip={sister.ip}
/> />
))} ))}
</SistersGrid> </SistersGrid>
<TwoColumnRow> <SectionTitle> </SectionTitle>
<div> {projects.length > 0 ? (
<SectionTitle> </SectionTitle> <ProjectsGrid>
<PlaceholderCard>Sprint 002 </PlaceholderCard> {projects.map((p) => (
</div> <ProjectCard
<div> key={p.id}
<SectionTitle> </SectionTitle> id={p.id}
<PlaceholderCard>Sprint 002 </PlaceholderCard> name={p.name}
</div> description={p.description}
</TwoColumnRow> status={p.status}
progress={p.progress}
doneTasks={p.doneTasks}
totalTasks={p.totalTasks}
openPRs={p.openPRs}
currentSprint={p.currentSprint}
/>
))}
</ProjectsGrid>
) : (
<TwoColumnRow>
<Card>
<PlaceholderText> </PlaceholderText>
</Card>
<Card>
<SectionTitle> </SectionTitle>
<ActivityFeed items={activityItems} />
</Card>
</TwoColumnRow>
)}
{projects.length > 0 && (
<TwoColumnRow>
<div />
<Card>
<SectionTitle> </SectionTitle>
<ActivityFeed items={activityItems} />
</Card>
</TwoColumnRow>
)}
</PageWrapper> </PageWrapper>
); );
} }

View File

@@ -0,0 +1,197 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import styled from 'styled-components';
import Link from 'next/link';
import TabNav from '@/components/common/TabNav';
import SprintAccordion from '@/components/projects/SprintAccordion';
import TaskTable from '@/components/projects/TaskTable';
import ActivityFeed from '@/components/dashboard/ActivityFeed';
import ProgressBar from '@/components/common/ProgressBar';
import { theme } from '@/styles/theme';
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3005';
const TABS = [
{ id: 'sprints', label: 'Sprints', icon: '📁' },
{ id: 'tasks', label: 'Task Ledger', icon: '📋' },
{ id: 'activity', label: '활동 로그', icon: '📝' },
];
const Page = styled.div`
padding: 32px;
min-height: 100vh;
background: ${theme.colors.bg};
`;
const BackLink = styled(Link)`
font-size: 13px;
color: ${theme.colors.textSecondary};
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 20px;
transition: color 0.15s;
&:hover { color: ${theme.colors.textPrimary}; }
`;
const Header = styled.div`
margin-bottom: 28px;
`;
const Title = styled.h1`
font-size: 22px;
font-weight: 700;
color: ${theme.colors.textPrimary};
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 12px;
`;
const GitLink = styled.a`
font-size: 12px;
color: ${theme.colors.accent};
padding: 3px 8px;
border: 1px solid rgba(88,166,255,0.3);
border-radius: 5px;
transition: background 0.15s;
&:hover { background: rgba(88,166,255,0.1); }
`;
const Desc = styled.p`
font-size: 14px;
color: ${theme.colors.textSecondary};
margin-bottom: 12px;
`;
const ProgressRow = styled.div`
display: flex;
align-items: center;
gap: 12px;
max-width: 400px;
margin-bottom: 8px;
`;
const ProgressLabel = styled.span`
font-size: 13px;
color: ${theme.colors.textSecondary};
white-space: nowrap;
`;
const PRCount = styled.span`
font-size: 12px;
color: ${theme.colors.accent};
padding: 2px 8px;
background: rgba(88,166,255,0.1);
border-radius: 4px;
`;
const Card = styled.div`
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 12px;
padding: 24px;
`;
const ErrorBanner = styled.div`
padding: 10px 14px;
background: rgba(255,23,68,0.08);
border: 1px solid rgba(255,23,68,0.3);
border-radius: 8px;
font-size: 13px;
color: ${theme.colors.offline};
margin-bottom: 20px;
`;
export default function ProjectDetailPage() {
const { id } = useParams<{ id: string }>();
const [project, setProject] = useState<any>(null);
const [tasks, setTasks] = useState<any[]>([]);
const [activity, setActivity] = useState<any[]>([]);
const [tab, setTab] = useState('sprints');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const load = async () => {
try {
const [projRes, tasksRes, actRes] = await Promise.all([
fetch(`${API_URL}/api/projects/${id}`),
fetch(`${API_URL}/api/projects/${id}/tasks`),
fetch(`${API_URL}/api/projects/${id}/activity`),
]);
if (!projRes.ok) throw new Error(`Project API ${projRes.status}`);
setProject(await projRes.json());
if (tasksRes.ok) setTasks(await tasksRes.json());
if (actRes.ok) {
const d = await actRes.json();
setActivity(d.items ?? []);
}
setError(null);
} catch (e) {
setError('API 연결 실패');
} finally {
setLoading(false);
}
};
load();
}, [id]);
const allTasks = project?.sprints?.flatMap((s: any) => s.tasks) ?? [];
const totalTasks = allTasks.length;
const doneTasks = allTasks.filter((t: any) => t.status === 'done').length;
const progress = totalTasks > 0 ? Math.round(doneTasks / totalTasks * 100) : 0;
return (
<Page>
<BackLink href="/"> </BackLink>
{error && <ErrorBanner> {error}</ErrorBanner>}
{loading ? (
<Desc> ...</Desc>
) : project ? (
<>
<Header>
<Title>
{project.name}
{project.repoUrl && (
<GitLink href={project.repoUrl} target="_blank" rel="noopener">
Gitea
</GitLink>
)}
{(project.openPRs?.length ?? 0) > 0 && (
<PRCount>PR {project.openPRs.length}</PRCount>
)}
</Title>
{project.description && <Desc>{project.description}</Desc>}
<ProgressRow>
<ProgressBar value={progress} />
<ProgressLabel>{progress}% ({doneTasks}/{totalTasks})</ProgressLabel>
</ProgressRow>
</Header>
<Card>
<TabNav tabs={TABS} active={tab} onChange={setTab} />
{tab === 'sprints' && (
<SprintAccordion sprints={tasks.length > 0 ? tasks : (project.sprints ?? [])} />
)}
{tab === 'tasks' && (
<TaskTable tasks={allTasks} />
)}
{tab === 'activity' && (
<ActivityFeed items={activity} />
)}
</Card>
</>
) : (
<Desc> </Desc>
)}
</Page>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import React from 'react';
import styled from 'styled-components';
import Sidebar from './Sidebar';
import { SidebarProvider, useSidebar } from '@/lib/SidebarContext';
const LayoutRoot = styled.div`
display: flex;
min-height: 100vh;
background: #0d1117;
`;
const MainContent = styled.main<{ $collapsed: boolean }>`
flex: 1;
margin-left: ${({ $collapsed }) => ($collapsed ? '60px' : '220px')};
min-height: 100vh;
transition: margin-left 0.2s ease;
`;
function Inner({ children }: { children: React.ReactNode }) {
const { collapsed } = useSidebar();
return (
<LayoutRoot>
<Sidebar />
<MainContent $collapsed={collapsed}>{children}</MainContent>
</LayoutRoot>
);
}
export default function LayoutShell({ children }: { children: React.ReactNode }) {
return (
<SidebarProvider>
<Inner>{children}</Inner>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import React from 'react';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
interface ProgressBarProps {
value: number; // 0-100
size?: 'sm' | 'md';
}
const Track = styled.div<{ $size: 'sm' | 'md' }>`
width: 100%;
height: ${({ $size }) => ($size === 'sm' ? '4px' : '6px')};
background: rgba(240, 246, 252, 0.08);
border-radius: 999px;
overflow: hidden;
`;
const Fill = styled.div<{ $value: number }>`
height: 100%;
width: ${({ $value }) => $value}%;
background: ${({ $value }) =>
$value >= 80 ? theme.colors.online :
$value >= 40 ? theme.colors.accent :
theme.colors.textSecondary};
border-radius: 999px;
transition: width 0.4s ease;
`;
export default function ProgressBar({ value, size = 'md' }: ProgressBarProps) {
return (
<Track $size={size}>
<Fill $value={Math.min(100, Math.max(0, value))} />
</Track>
);
}

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useSidebar } from '@/lib/SidebarContext';
const MENU_ITEMS = [ const MENU_ITEMS = [
{ href: '/', icon: '⬛', label: '대시보드' }, { href: '/', icon: '⬛', label: '대시보드' },
@@ -106,7 +107,7 @@ const AdminSection = styled.div`
`; `;
export default function Sidebar() { export default function Sidebar() {
const [collapsed, setCollapsed] = useState(false); const { collapsed, setCollapsed } = useSidebar();
const pathname = usePathname(); const pathname = usePathname();
return ( return (

View File

@@ -2,6 +2,7 @@
import React from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { theme } from '@/styles/theme';
type Status = 'online' | 'offline' | 'working' | 'unknown'; type Status = 'online' | 'offline' | 'working' | 'unknown';
@@ -17,10 +18,10 @@ const statusLabels: Record<Status, string> = {
}; };
const statusColors: Record<Status, string> = { const statusColors: Record<Status, string> = {
online: '#00E676', online: theme.colors.online,
offline: '#FF1744', offline: theme.colors.offline,
working: '#2979FF', working: theme.colors.working,
unknown: '#8B949E', unknown: theme.colors.textSecondary,
}; };
const BadgeWrapper = styled.span<{ $status: Status }>` const BadgeWrapper = styled.span<{ $status: Status }>`

View File

@@ -0,0 +1,56 @@
'use client';
import React from 'react';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
export interface Tab {
id: string;
label: string;
icon?: string;
}
interface TabNavProps {
tabs: Tab[];
active: string;
onChange: (id: string) => void;
}
const Nav = styled.div`
display: flex;
gap: 4px;
border-bottom: 1px solid ${theme.colors.border};
margin-bottom: 20px;
`;
const TabBtn = styled.button<{ $active: boolean }>`
padding: 10px 16px;
background: none;
border: none;
border-bottom: 2px solid ${({ $active }) => $active ? theme.colors.accent : 'transparent'};
color: ${({ $active }) => $active ? theme.colors.textPrimary : theme.colors.textSecondary};
font-size: 14px;
font-weight: ${({ $active }) => $active ? '600' : '400'};
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
display: flex;
align-items: center;
gap: 6px;
&:hover {
color: ${theme.colors.textPrimary};
}
`;
export default function TabNav({ tabs, active, onChange }: TabNavProps) {
return (
<Nav>
{tabs.map((tab) => (
<TabBtn key={tab.id} $active={active === tab.id} onClick={() => onChange(tab.id)}>
{tab.icon && <span>{tab.icon}</span>}
{tab.label}
</TabBtn>
))}
</Nav>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import React from 'react';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
type TaskStatus = 'pending' | 'in_progress' | 'review' | 'done' | 'failed' | 'blocked' | 'escalated';
interface TaskBadgeProps {
status: TaskStatus;
}
const statusConfig: Record<TaskStatus, { label: string; color: string; bg: string }> = {
pending: { label: '대기', color: theme.colors.textSecondary, bg: 'rgba(139,148,158,0.1)' },
in_progress: { label: '진행중', color: theme.colors.working, bg: 'rgba(41,121,255,0.12)' },
review: { label: '리뷰', color: '#FF9800', bg: 'rgba(255,152,0,0.12)' },
done: { label: '완료', color: theme.colors.online, bg: 'rgba(0,230,118,0.12)' },
failed: { label: '실패', color: theme.colors.offline, bg: 'rgba(255,23,68,0.12)' },
blocked: { label: '블로킹', color: '#FF5722', bg: 'rgba(255,87,34,0.12)' },
escalated: { label: '에스컬레이션', color: '#E040FB', bg: 'rgba(224,64,251,0.12)' },
};
const Badge = styled.span<{ $color: string; $bg: string }>`
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
color: ${({ $color }) => $color};
background: ${({ $bg }) => $bg};
white-space: nowrap;
`;
export default function TaskBadge({ status }: TaskBadgeProps) {
const cfg = statusConfig[status] ?? statusConfig.pending;
return <Badge $color={cfg.color} $bg={cfg.bg}>{cfg.label}</Badge>;
}

View File

@@ -0,0 +1,115 @@
'use client';
import React from 'react';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
interface ActivityItem {
id: number;
action: string;
detail: string | null;
createdAt: string;
sister?: { name: string } | null;
project?: { name: string } | null;
}
const actionEmojis: Record<string, string> = {
sprint_created: '📁',
task_updated: '✏️',
deploy_complete: '🚀',
ssh_check: '🔍',
pr_opened: '📋',
pr_merged: '✅',
};
function formatTime(iso: string): string {
const d = new Date(iso);
const diff = Date.now() - d.getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return '방금';
if (min < 60) return `${min}분 전`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}시간 전`;
return `${Math.floor(hr / 24)}일 전`;
}
const List = styled.ul`
list-style: none;
display: flex;
flex-direction: column;
gap: 0;
`;
const Item = styled.li`
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid ${theme.colors.border};
&:last-child {
border-bottom: none;
}
`;
const Emoji = styled.span`
font-size: 15px;
margin-top: 1px;
flex-shrink: 0;
`;
const Body = styled.div`
flex: 1;
min-width: 0;
`;
const Action = styled.div`
font-size: 13px;
color: ${theme.colors.textPrimary};
margin-bottom: 2px;
`;
const Detail = styled.div`
font-size: 12px;
color: ${theme.colors.textSecondary};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const Time = styled.span`
font-size: 11px;
color: ${theme.colors.textSecondary};
flex-shrink: 0;
margin-top: 2px;
`;
const Empty = styled.div`
padding: 24px;
text-align: center;
color: ${theme.colors.textSecondary};
font-size: 13px;
`;
export default function ActivityFeed({ items }: { items: ActivityItem[] }) {
if (!items.length) return <Empty> </Empty>;
return (
<List>
{items.map((item) => (
<Item key={item.id}>
<Emoji>{actionEmojis[item.action] ?? '📝'}</Emoji>
<Body>
<Action>
{item.sister && <strong>{item.sister.name}</strong>}
{item.sister && ' · '}
{item.action.replace(/_/g, ' ')}
</Action>
{item.detail && <Detail>{item.detail}</Detail>}
</Body>
<Time>{formatTime(item.createdAt)}</Time>
</Item>
))}
</List>
);
}

View File

@@ -3,6 +3,7 @@
import React from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import StatusBadge from '../common/StatusBadge'; import StatusBadge from '../common/StatusBadge';
import { theme } from '@/styles/theme';
type Status = 'online' | 'offline' | 'working' | 'unknown'; type Status = 'online' | 'offline' | 'working' | 'unknown';
@@ -12,14 +13,13 @@ interface SisterCardProps {
status: Status; status: Status;
lastSeen: string | null; lastSeen: string | null;
currentTask: string | null; currentTask: string | null;
ip: string;
} }
const statusBorderColors: Record<Status, string> = { const statusBorderColors: Record<Status, string> = {
online: '#00E676', online: theme.colors.online,
offline: '#FF1744', offline: theme.colors.offline,
working: '#2979FF', working: theme.colors.working,
unknown: '#8B949E', unknown: theme.colors.textSecondary,
}; };
const sisterEmojis: Record<string, string> = { const sisterEmojis: Record<string, string> = {
@@ -130,7 +130,6 @@ export default function SisterCard({
status, status,
lastSeen, lastSeen,
currentTask, currentTask,
ip,
}: SisterCardProps) { }: SisterCardProps) {
return ( return (
<Card $status={status}> <Card $status={status}>
@@ -147,9 +146,6 @@ export default function SisterCard({
<MetaRow> <MetaRow>
: {formatLastSeen(lastSeen)} : {formatLastSeen(lastSeen)}
</MetaRow> </MetaRow>
<MetaRow>
<IpBadge>{ip}</IpBadge>
</MetaRow>
{currentTask && <CurrentTask>📌 {currentTask}</CurrentTask>} {currentTask && <CurrentTask>📌 {currentTask}</CurrentTask>}
</Card> </Card>
); );

View File

@@ -0,0 +1,115 @@
'use client';
import React from 'react';
import styled from 'styled-components';
import Link from 'next/link';
import ProgressBar from '../common/ProgressBar';
import { theme } from '@/styles/theme';
interface ProjectCardProps {
id: number;
name: string;
description: string | null;
status: string;
progress: number;
doneTasks: number;
totalTasks: number;
openPRs: number;
currentSprint: string | null;
}
const Card = styled(Link)`
display: block;
background: ${theme.colors.cardBg};
backdrop-filter: blur(12px);
border: 1px solid ${theme.colors.border};
border-radius: 12px;
padding: 20px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
text-decoration: none;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
border-color: rgba(88,166,255,0.3);
}
`;
const Header = styled.div`
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
gap: 8px;
`;
const Name = styled.span`
font-size: 15px;
font-weight: 600;
color: ${theme.colors.textPrimary};
`;
const Meta = styled.div`
font-size: 12px;
color: ${theme.colors.textSecondary};
margin-bottom: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const Stats = styled.div`
display: flex;
gap: 16px;
margin-top: 12px;
`;
const Stat = styled.div`
font-size: 12px;
color: ${theme.colors.textSecondary};
display: flex;
align-items: center;
gap: 4px;
`;
const ProgressLabel = styled.div`
display: flex;
justify-content: space-between;
margin-bottom: 6px;
`;
const ProgressText = styled.span`
font-size: 11px;
color: ${theme.colors.textSecondary};
`;
const PRBadge = styled.span<{ $count: number }>`
font-size: 11px;
padding: 2px 7px;
border-radius: 4px;
background: ${({ $count }) => $count > 0 ? 'rgba(88,166,255,0.12)' : 'rgba(139,148,158,0.08)'};
color: ${({ $count }) => $count > 0 ? theme.colors.accent : theme.colors.textSecondary};
font-weight: 600;
`;
export default function ProjectCard({
id, name, description, progress, doneTasks, totalTasks, openPRs, currentSprint,
}: ProjectCardProps) {
return (
<Card href={`/projects/${id}`}>
<Header>
<Name>{name}</Name>
<PRBadge $count={openPRs}>PR {openPRs}</PRBadge>
</Header>
{description && <Meta>{description}</Meta>}
{currentSprint && (
<Meta>📌 {currentSprint}</Meta>
)}
<ProgressLabel>
<ProgressText></ProgressText>
<ProgressText>{progress}% ({doneTasks}/{totalTasks})</ProgressText>
</ProgressLabel>
<ProgressBar value={progress} size="sm" />
</Card>
);
}

View File

@@ -0,0 +1,201 @@
'use client';
import React, { useState } from 'react';
import styled from 'styled-components';
import TaskBadge from '../common/TaskBadge';
import ProgressBar from '../common/ProgressBar';
import { theme } from '@/styles/theme';
interface Task {
id: number;
taskId: string;
title: string;
assignee: string;
status: string;
iteration: number;
}
interface Sprint {
id: number;
number: number;
name: string;
status: string;
progress: number;
tasks: Task[];
}
const sprintStatusIcon: Record<string, string> = {
pending: '⏳',
in_progress: '🔄',
review: '🔍',
done: '✅',
failed: '❌',
};
const sprintStatusColor: Record<string, string> = {
pending: theme.colors.textSecondary,
in_progress: theme.colors.working,
review: '#FF9800',
done: theme.colors.online,
failed: theme.colors.offline,
};
const assigneeEmojis: Record<string, string> = {
harang: '🦊', narang: '🦊', darang: '🐱', erang: '🐺',
};
const Wrapper = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const AccordionItem = styled.div<{ $open: boolean }>`
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 10px;
overflow: hidden;
transition: border-color 0.2s;
${({ $open }) => $open && `border-color: rgba(88,166,255,0.3);`}
`;
const Header = styled.button<{ $statusColor: string }>`
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
background: none;
border: none;
cursor: pointer;
text-align: left;
color: ${theme.colors.textPrimary};
transition: background 0.15s;
&:hover {
background: rgba(240, 246, 252, 0.04);
}
`;
const SprintNum = styled.span<{ $color: string }>`
font-size: 13px;
font-weight: 700;
color: ${({ $color }) => $color};
min-width: 60px;
`;
const SprintName = styled.span`
font-size: 14px;
font-weight: 600;
flex: 1;
`;
const ProgressWrap = styled.div`
width: 80px;
`;
const Chevron = styled.span<{ $open: boolean }>`
font-size: 12px;
color: ${theme.colors.textSecondary};
transform: ${({ $open }) => $open ? 'rotate(90deg)' : 'none'};
transition: transform 0.2s;
`;
const Body = styled.div<{ $open: boolean }>`
display: ${({ $open }) => $open ? 'block' : 'none'};
border-top: 1px solid ${theme.colors.border};
`;
const TaskRow = styled.div`
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid ${theme.colors.border};
font-size: 13px;
&:last-child { border-bottom: none; }
&:hover { background: rgba(240,246,252,0.03); }
`;
const TaskId = styled.code`
font-size: 11px;
color: ${theme.colors.accent};
min-width: 70px;
font-family: monospace;
`;
const TaskTitle = styled.span`
flex: 1;
color: ${theme.colors.textPrimary};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const Assignee = styled.span`
font-size: 12px;
color: ${theme.colors.textSecondary};
min-width: 60px;
`;
const Iteration = styled.span<{ $count: number }>`
font-size: 11px;
color: ${({ $count }) => $count > 0 ? '#FF9800' : theme.colors.textSecondary};
min-width: 28px;
text-align: right;
`;
export default function SprintAccordion({ sprints }: { sprints: Sprint[] }) {
const [openIds, setOpenIds] = useState<Set<number>>(
() => new Set(sprints.filter((s) => s.status === 'in_progress').map((s) => s.id))
);
const toggle = (id: number) => {
setOpenIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
return (
<Wrapper>
{sprints.map((sprint) => {
const open = openIds.has(sprint.id);
const color = sprintStatusColor[sprint.status] ?? theme.colors.textSecondary;
return (
<AccordionItem key={sprint.id} $open={open}>
<Header $statusColor={color} onClick={() => toggle(sprint.id)}>
<SprintNum $color={color}>
{sprintStatusIcon[sprint.status] ?? '⏳'} #{sprint.number}
</SprintNum>
<SprintName>{sprint.name}</SprintName>
<ProgressWrap>
<ProgressBar value={sprint.progress} size="sm" />
</ProgressWrap>
<Chevron $open={open}></Chevron>
</Header>
<Body $open={open}>
{sprint.tasks.length === 0 ? (
<TaskRow><TaskTitle style={{ color: theme.colors.textSecondary }}> </TaskTitle></TaskRow>
) : (
sprint.tasks.map((task) => (
<TaskRow key={task.id}>
<TaskId>{task.taskId}</TaskId>
<TaskTitle>{task.title}</TaskTitle>
<Assignee>{assigneeEmojis[task.assignee] ?? '🤖'} {task.assignee}</Assignee>
<TaskBadge status={task.status as any} />
<Iteration $count={task.iteration}>
{task.iteration > 0 ? `×${task.iteration}` : ''}
</Iteration>
</TaskRow>
))
)}
</Body>
</AccordionItem>
);
})}
</Wrapper>
);
}

View File

@@ -0,0 +1,129 @@
'use client';
import React, { useState } from 'react';
import styled from 'styled-components';
import TaskBadge from '../common/TaskBadge';
import { theme } from '@/styles/theme';
interface Task {
id: number;
taskId: string;
title: string;
assignee: string;
status: string;
iteration: number;
createdAt: string;
}
const ALL_STATUSES = ['all', 'pending', 'in_progress', 'review', 'done', 'failed', 'blocked', 'escalated'];
const Wrapper = styled.div``;
const Filters = styled.div`
display: flex;
gap: 6px;
margin-bottom: 16px;
flex-wrap: wrap;
`;
const FilterBtn = styled.button<{ $active: boolean }>`
padding: 4px 10px;
border-radius: 6px;
border: 1px solid ${({ $active }) => $active ? theme.colors.accent : theme.colors.border};
background: ${({ $active }) => $active ? 'rgba(88,166,255,0.12)' : 'transparent'};
color: ${({ $active }) => $active ? theme.colors.accent : theme.colors.textSecondary};
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
&:hover { border-color: ${theme.colors.accent}; color: ${theme.colors.accent}; }
`;
const Table = styled.table`
width: 100%;
border-collapse: collapse;
`;
const Th = styled.th`
text-align: left;
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
color: ${theme.colors.textSecondary};
border-bottom: 1px solid ${theme.colors.border};
text-transform: uppercase;
letter-spacing: 0.05em;
`;
const Td = styled.td`
padding: 10px 12px;
font-size: 13px;
border-bottom: 1px solid ${theme.colors.border};
color: ${theme.colors.textPrimary};
`;
const Tr = styled.tr`
&:last-child td { border-bottom: none; }
&:hover td { background: rgba(240,246,252,0.03); }
`;
const TaskIdCell = styled.code`
color: ${theme.colors.accent};
font-family: monospace;
font-size: 12px;
`;
const IterationCell = styled.span<{ $count: number }>`
color: ${({ $count }) => $count > 0 ? '#FF9800' : theme.colors.textSecondary};
`;
const Empty = styled.div`
padding: 32px;
text-align: center;
color: ${theme.colors.textSecondary};
font-size: 13px;
`;
export default function TaskTable({ tasks }: { tasks: Task[] }) {
const [filter, setFilter] = useState('all');
const filtered = filter === 'all' ? tasks : tasks.filter((t) => t.status === filter);
return (
<Wrapper>
<Filters>
{ALL_STATUSES.map((s) => (
<FilterBtn key={s} $active={filter === s} onClick={() => setFilter(s)}>
{s === 'all' ? '전체' : s}
</FilterBtn>
))}
</Filters>
{filtered.length === 0 ? (
<Empty> </Empty>
) : (
<Table>
<thead>
<tr>
<Th>ID</Th>
<Th></Th>
<Th></Th>
<Th></Th>
<Th></Th>
</tr>
</thead>
<tbody>
{filtered.map((task) => (
<Tr key={task.id}>
<Td><TaskIdCell>{task.taskId}</TaskIdCell></Td>
<Td>{task.title}</Td>
<Td>{task.assignee}</Td>
<Td><TaskBadge status={task.status as any} /></Td>
<Td><IterationCell $count={task.iteration}>{task.iteration || '-'}</IterationCell></Td>
</Tr>
))}
</tbody>
</Table>
)}
</Wrapper>
);
}

View File

@@ -0,0 +1,26 @@
'use client';
import React, { createContext, useContext, useState } from 'react';
interface SidebarContextValue {
collapsed: boolean;
setCollapsed: (v: boolean) => void;
}
const SidebarContext = createContext<SidebarContextValue>({
collapsed: false,
setCollapsed: () => {},
});
export function SidebarProvider({ children }: { children: React.ReactNode }) {
const [collapsed, setCollapsed] = useState(false);
return (
<SidebarContext.Provider value={{ collapsed, setCollapsed }}>
{children}
</SidebarContext.Provider>
);
}
export function useSidebar() {
return useContext(SidebarContext);
}

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"next": "16.2.2", "next": "16.2.2",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"styled-components": "^6.3.12" "styled-components": "^6.3.12"
}, },
"devDependencies": { "devDependencies": {