diff --git a/.claude/commands/init-workflow.md b/.claude/commands/init-workflow.md new file mode 100644 index 0000000..c9ff9d6 --- /dev/null +++ b/.claude/commands/init-workflow.md @@ -0,0 +1,167 @@ +# init-workflow + +Git worktree를 자동으로 생성하는 커맨드입니다. + +브랜치명을 입력받아 자동으로: +1. 브랜치명은 사용자 입력 그대로 사용 (예: `20260127_#16_...`) +2. 브랜치가 없으면 생성 (현재 브랜치에서 분기) +3. 폴더명만 특수문자(`#` 포함)를 `_`로 변환하여 생성 (예: `20260127_16_...`) +4. `{프로젝트명}-Worktree` 폴더에 worktree 생성 (예: `RomRom-FE-Worktree`) +5. 설정 파일 자동 복사 (Firebase, iOS, Android 키 등) +6. 이미 존재하면 경로만 출력 + +## 사용법 + +``` +/init-workflow + +20260120_#163_Github_Projects_에_대한_템플릿_개발_필요_및_관련_Sync_워크플로우_개발_필요 +``` + +## 실행 로직 + +1. 사용자 입력에서 두 번째 줄의 브랜치명 추출 +2. 브랜치명은 그대로 사용 (임시 Python 스크립트에 포함) +3. 임시 Python 스크립트 파일 생성 (인코딩 문제 해결) +4. Python 스크립트 실행 (worktree 생성 + 설정 파일 복사) +5. 임시 파일 자동 삭제 +6. 결과 출력 + +--- + +사용자 입력에서 두 번째 줄을 추출하여 브랜치명으로 사용하세요. + +브랜치명이 제공되지 않은 경우: +- 사용법을 안내하세요. + +브랜치명이 제공된 경우: +1. 프로젝트 루트로 이동 +2. Git 긴 경로 지원 활성화: `git config --global core.longpaths true` (최초 1회만 실행) +3. 브랜치명은 사용자 입력 그대로 사용 (예: `20260116_#432_...` 그대로 유지) +4. 임시 Python 스크립트 파일 생성: + - 파일명: `init_worktree_temp_{timestamp}.py` + - 브랜치명을 코드에 직접 포함 (인코딩 문제 해결) + - worktree 생성 로직 포함 +5. **Python 스크립트 실행** (Windows에서는 `-X utf8` 플래그 필수): + ```bash + python -X utf8 init_worktree_temp_{timestamp}.py + ``` +6. 임시 파일 삭제 +7. 결과 출력 +8. 에이전트가 `.gitignore` 분석 후 민감 파일 복사 + +**중요**: +- **브랜치명 처리**: 브랜치명은 사용자 입력 그대로 사용됩니다 (`#` 문자 포함) +- **폴더명 처리**: 폴더명만 `normalize_branch_name()` 함수로 특수문자(`#` 포함)를 `_`로 변환합니다 +- **인코딩 문제 해결**: Python 스크립트 파일에 브랜치명을 직접 포함시켜 Windows PowerShell 인코딩 문제 회피 +- **Windows UTF-8 모드**: Python 실행 시 `-X utf8` 플래그 사용 필수 +- **설정 파일 자동 복사**: worktree 생성 후 에이전트가 동적으로 파일 복사 +- **플랫폼 독립성**: Windows/macOS/Linux 모두 동일한 방식으로 처리 + +**실행 예시**: +```powershell +# Windows PowerShell +cd d:\0-suh\project\RomRom-FE +git config --global core.longpaths true + +# Python UTF-8 모드로 실행 (Windows 한글 인코딩 문제 해결) +python -X utf8 init_worktree_temp.py + +# 입력 브랜치명: 20260116_#432_UX_개선_및_페이지_디자인_수정 +# → Git 브랜치: 20260116_#432_UX_개선_및_페이지_디자인_수정 (# 유지) +# → 폴더명: 20260116_432_UX_개선_및_페이지_디자인_수정 (# → _ 변환) +``` + +**Python 스크립트 구조**: +```python +# -*- coding: utf-8 -*- +import sys +import os +import shutil +import glob + +# 프로젝트 루트로 이동 +os.chdir('프로젝트_루트_경로') + +# 브랜치명 (사용자 입력 그대로) +branch_name = '20260116_#432_UX_개선_및_페이지_디자인_수정' + +# worktree_manager 실행 +sys.path.insert(0, '.cursor/scripts') +import worktree_manager +os.environ['GIT_BRANCH_NAME'] = branch_name +os.environ['PYTHONIOENCODING'] = 'utf-8' +sys.argv = ['worktree_manager.py'] +exit_code = worktree_manager.main() + +# worktree 경로를 환경변수로 설정 (에이전트가 파일 복사에 사용) +if exit_code == 0: + import subprocess + result = subprocess.run(['git', 'worktree', 'list', '--porcelain'], + capture_output=True, text=True, encoding='utf-8') + lines = result.stdout.split('\n') + worktree_path = None + for i, line in enumerate(lines): + if line.startswith(f'branch refs/heads/{branch_name}'): + worktree_path = lines[i-1].replace('worktree ', '') + break + + if worktree_path: + print(f'📍 WORKTREE_PATH={worktree_path}') + +sys.exit(exit_code) +``` + +## 설정 파일 복사 (에이전트 동적 판단) + +Worktree 생성 성공 후, **에이전트가 `.gitignore`를 분석하여 민감 파일을 동적으로 판단**하고 복사합니다. + +### Step 1: .gitignore 분석 + +프로젝트 `.gitignore` 파일을 읽고 다음 카테고리의 민감 파일 패턴을 식별합니다: + +| 카테고리 | 식별 패턴 | 설명 | +|---------|----------|------| +| Firebase 설정 | `google-services.json`, `GoogleService-Info.plist` | Firebase 연동 설정 | +| 서명 키/인증서 | `key.properties`, `*.jks`, `*.p12`, `*.p8`, `*.mobileprovision` | 앱 서명 인증서 | +| 빌드 설정 | `Secrets.xcconfig`, 민감한 `*.xcconfig` | iOS 빌드 비밀 설정 | +| 환경 변수 | `*.env` | 환경별 설정 파일 | +| IDE 로컬 설정 | `settings.local.json` | Claude/Cursor 로컬 설정 | + +### Step 2: 실제 파일 확인 및 복사 + +1. `.gitignore`에 명시된 패턴 중 **실제 존재하는 파일** 확인 +2. 존재하는 파일만 worktree 경로로 복사 +3. 디렉토리 구조 유지 (예: `android/app/google-services.json` → `worktree/android/app/google-services.json`) + +**복사 명령 예시**: +```bash +# Python shutil 사용 +import shutil +shutil.copy2('원본경로', 'worktree경로/원본경로') +``` + +### Step 3: 복사 제외 대상 (절대 복사 금지) + +다음은 민감 파일이더라도 **절대 복사하지 않습니다**: + +| 경로/패턴 | 이유 | +|----------|------| +| `build/`, `target/`, `.gradle/` | 빌드 산출물 (새로 빌드 필요) | +| `node_modules/`, `Pods/`, `.dart_tool/` | 의존성 (새로 설치 필요) | +| `.report/`, `.run/` | 보고서 (worktree별로 별도 생성) | +| `.idea/` | IDE 캐시 전체 | +| `*.log`, `*.class`, `*.pyc` | 임시/컴파일 파일 | + +### Step 4: 결과 출력 + +복사된 파일 목록을 ✅ 이모지와 함께 출력합니다: +``` +✅ android/app/google-services.json 복사 완료 +✅ ios/Runner/GoogleService-Info.plist 복사 완료 +✅ android/key.properties 복사 완료 +``` + +**참고**: +- 파일이 존재하지 않으면 해당 복사는 자동으로 건너뜁니다. +- 에이전트가 `.gitignore`를 분석하여 복사 대상을 동적으로 결정합니다. diff --git a/.claude/commands/report.md b/.claude/commands/report.md index 3172643..9055108 100644 --- a/.claude/commands/report.md +++ b/.claude/commands/report.md @@ -12,6 +12,52 @@ - ✅ 특이한 부분이나 주의할 점은 간단히 설명 - ✅ 민감 정보 마스킹: API Key, Password, Token, Secret 등은 반드시 마스킹 처리 +## ⛔ 금지 사항 (필수) + +보고서 작성 시 다음 내용은 **절대 포함하지 않음**: + +### 절대 사용 금지 필드 (메타 정보) +다음 필드는 어떤 형태로든 보고서에 포함하지 않음: + +- ❌ `**작성자**:` / `**작업자**:` / `**담당자**:` 필드 +- ❌ `**작성일**:` / `**작업일**:` / `**날짜**:` 필드 (보고서 본문에서) +- ❌ `## 작성 정보` 같은 메타 정보 섹션 +- ❌ 보고서 상단에 이슈 링크 + 날짜 + 작성자 조합 + +### 절대 사용 금지 단어/표현 +- ❌ `Claude`, `Claude Code`, `claude-opus`, `claude-sonnet` 등 AI 이름 +- ❌ `Cursor`, `Copilot`, `GPT`, `ChatGPT` 등 AI 도구명 +- ❌ `AI`, `인공지능`, `자동 생성`, `자동 작성` 등의 표현 +- ❌ `Co-Authored-By: Claude` 같은 서명 +- ❌ 모델명 (Sonnet, Opus, GPT-4 등) + +### 잘못된 예시 (절대 사용 금지) +```markdown +## 작성 정보 +- **작성일**: 2026-01-20 +- **작성자**: Claude Code (claude-opus-4-5-20251101) + +**이슈**: [#152](...) +**작성일**: 2026-01-12 +**작성자**: Claude Code + +**이슈**: [#145](...) +**작업일**: 2026-01-12 +**작업자**: Claude Code +``` + +### 올바른 예시 +보고서는 **작업 내용만** 포함하고, 메타 정보(작성자/작성일)는 완전히 제외: +```markdown +### 📌 작업 개요 +[작업 내용 설명] + +### ✅ 구현 내용 +[구현 내용...] +``` + +**핵심**: 보고서에는 **작성자/작성일 관련 필드를 절대 포함하지 않음**. 파일명에 날짜가 포함되므로 별도 기록 불필요. + ## 🔒 민감 정보 마스킹 (필수) 보고서 작성 시 다음 민감 정보는 반드시 마스킹 처리: diff --git a/.claude/scripts/README.md b/.claude/scripts/README.md new file mode 100644 index 0000000..05b666f --- /dev/null +++ b/.claude/scripts/README.md @@ -0,0 +1,54 @@ +# Claude Scripts + +이 디렉토리는 Claude에서 공통으로 사용할 수 있는 Python 유틸리티 스크립트를 포함합니다. + +## 📦 포함된 모듈 + +### `worktree_manager.py` (v1.0.0) + +Git worktree를 자동으로 생성하고 관리하는 스크립트입니다. + +#### 기능 +- 브랜치가 없으면 자동 생성 (현재 브랜치에서 분기) +- 브랜치명의 특수문자(`#`, `/`, `\` 등)를 안전하게 처리 +- `RomRom-Worktree` 폴더에 worktree 자동 생성 +- 이미 존재하는 worktree는 건너뛰고 경로만 출력 + +#### 사용법 + +**직접 실행:** + +```bash +python .claude/scripts/worktree_manager.py "20260120_#163_Github_Projects_에_대한_템플릿_개발_필요" +``` + +#### 출력 예시 + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🌿 Git Worktree Manager v1.0.0 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +📋 입력된 브랜치: 20260120_#163_Github_Projects_에_대한_템플릿_개발_필요 +📁 폴더명: 20260120_163_Github_Projects_에_대한_템플릿_개발_필요 + +🔍 브랜치 확인 중... +⚠️ 브랜치가 존재하지 않습니다. +🔄 현재 브랜치(main)에서 새 브랜치 생성 중... +✅ 브랜치 생성 완료! + +📂 Worktree 경로: /Users/.../project/RomRom-Worktree/20260120_163_Github_Projects_에_대한_템플릿_개발_필요 + +🔄 Worktree 생성 중... +✅ Worktree 생성 완료! + +📍 경로: /Users/.../project/RomRom-Worktree/20260120_163_Github_Projects_에_대한_템플릿_개발_필요 +``` + +## ✅ 장점 + +- 🌏 **한글 경로 완벽 지원**: UTF-8 인코딩으로 저장되어 안전 +- 🔄 **재사용 가능**: 모든 프로젝트에서 사용 가능 +- 📝 **영구 보관**: 삭제되지 않고 계속 사용 가능 +- 🤖 **자동화**: 브랜치 생성부터 worktree 생성까지 자동화 +- 📚 **문서화**: 모든 함수에 docstring 포함 diff --git a/.claude/scripts/worktree_manager.py b/.claude/scripts/worktree_manager.py new file mode 100644 index 0000000..6e474e6 --- /dev/null +++ b/.claude/scripts/worktree_manager.py @@ -0,0 +1,621 @@ +# -*- coding: utf-8 -*- +""" +Git Worktree Manager v1.0.4 + +Git worktree를 자동으로 생성하고 관리하는 스크립트입니다. +브랜치가 없으면 자동으로 생성하고, 브랜치명의 특수문자를 안전하게 처리합니다. + +사용법: + macOS/Linux: + python worktree_manager.py + + Windows (환경 변수 방식, 권장): + $env:GIT_BRANCH_NAME = "브랜치명" + $env:PYTHONIOENCODING = "utf-8" + python -X utf8 worktree_manager.py + +예시: + python worktree_manager.py "20260120_#163_Github_Projects_에_대한_템플릿_개발_필요" + +Author: Cursor AI Assistant +Version: 1.0.4 +""" + +import os +import sys +import subprocess +import re +import platform +import io +from pathlib import Path +from typing import Dict, Optional, Tuple + +# Windows 인코딩 문제 해결 - stdout/stderr를 UTF-8로 래핑 +if platform.system() == 'Windows': + try: + # stdout/stderr가 버퍼를 가지고 있는 경우에만 래핑 + if hasattr(sys.stdout, 'buffer'): + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + if hasattr(sys.stderr, 'buffer'): + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') + except Exception: + pass # 래핑 실패 시 무시 + + +# =================================================================== +# 상수 정의 +# =================================================================== + +VERSION = "1.0.4" + +# Windows 환경 감지 +IS_WINDOWS = platform.system() == 'Windows' + +# 폴더명에서 제거할 특수문자 (파일시스템에서 안전하지 않은 문자) +SPECIAL_CHARS_PATTERN = r'[#/\\:*?"<>|]' + +# Worktree 루트 폴더명 (동적으로 설정됨) +# 예: RomRom-FE → RomRom-FE-Worktree +WORKTREE_ROOT_NAME = None # get_worktree_root()에서 동적으로 설정 + + +# =================================================================== +# 유틸리티 함수 +# =================================================================== + +def get_branch_name() -> str: + """ + 브랜치명을 안전하게 받기 (Windows 인코딩 문제 해결) + + Windows 환경에서 PowerShell → Python 스크립트로 한글 브랜치명을 전달할 때 + 인코딩 문제가 발생하므로, 환경 변수나 파일에서 읽는 방식을 우선 사용합니다. + + Returns: + str: 브랜치명 (비어있을 수 있음) + """ + if IS_WINDOWS: + # 방법 1: 환경 변수에서 읽기 (가장 간단하고 안전) + branch_name_raw = os.environ.get('GIT_BRANCH_NAME', '') + if branch_name_raw: + try: + branch_name = branch_name_raw.strip() + if branch_name: + return branch_name + except Exception: + pass + + # 방법 2: 임시 파일에서 읽기 + temp_file = os.environ.get('BRANCH_NAME_FILE', '') + if temp_file and os.path.exists(temp_file): + try: + encodings = ['utf-8', 'utf-8-sig', 'cp949', 'euc-kr'] + for encoding in encodings: + try: + with open(temp_file, 'r', encoding=encoding) as f: + branch_name = f.read().strip() + if branch_name: + return branch_name + except (UnicodeDecodeError, UnicodeError): + continue + except Exception: + pass + + # 방법 3: stdin에서 읽기 시도 + if not sys.stdin.isatty(): + try: + branch_name = sys.stdin.read().strip() + if branch_name: + return branch_name + except Exception: + pass + + # 기본: sys.argv에서 받기 + if len(sys.argv) >= 2: + return sys.argv[1].strip() + + return '' + + +def print_header(): + """헤더 출력""" + print("━" * 60) + print(f"🌿 Git Worktree Manager v{VERSION}") + print("━" * 60) + print() + + +def print_step(emoji: str, message: str): + """단계별 메시지 출력""" + print(f"{emoji} {message}") + + +def print_error(message: str): + """에러 메시지 출력""" + print(f"❌ 에러: {message}", file=sys.stderr) + + +def print_success(message: str): + """성공 메시지 출력""" + print(f"✅ {message}") + + +def print_info(message: str): + """정보 메시지 출력""" + print(f"ℹ️ {message}") + + +def print_warning(message: str): + """경고 메시지 출력""" + print(f"⚠️ {message}") + + +# =================================================================== +# Git 관련 함수 +# =================================================================== + +def run_git_command(args: list, check: bool = True) -> Tuple[bool, str, str]: + """ + Git 명령어 실행 + + Args: + args: Git 명령어 인자 리스트 (예: ['branch', '--list']) + check: 에러 발생 시 예외를 발생시킬지 여부 + + Returns: + Tuple[bool, str, str]: (성공 여부, stdout, stderr) + """ + try: + result = subprocess.run( + ['git'] + args, + capture_output=True, + text=True, + encoding='utf-8', + check=check + ) + return True, result.stdout.strip(), result.stderr.strip() + except subprocess.CalledProcessError as e: + return False, e.stdout.strip() if e.stdout else "", e.stderr.strip() if e.stderr else "" + except Exception as e: + return False, "", str(e) + + +def check_and_enable_longpaths() -> bool: + """ + Windows에서 Git 긴 경로 지원 확인 및 활성화 + + Returns: + bool: 긴 경로 지원이 활성화되어 있으면 True + """ + if not IS_WINDOWS: + return True + + # 현재 설정 확인 + success, stdout, _ = run_git_command(['config', '--global', 'core.longpaths'], check=False) + if success and stdout.strip().lower() == 'true': + return True + + # 긴 경로 지원 활성화 + print_info("Windows 긴 경로 지원을 활성화합니다...") + success, _, stderr = run_git_command(['config', '--global', 'core.longpaths', 'true'], check=False) + if success: + print_success("긴 경로 지원이 활성화되었습니다.") + return True + else: + print_warning(f"긴 경로 지원 활성화 실패: {stderr}") + print_warning("수동으로 실행하세요: git config --global core.longpaths true") + return False + + +def is_git_repository() -> bool: + """현재 디렉토리가 Git 저장소인지 확인""" + success, _, _ = run_git_command(['rev-parse', '--git-dir'], check=False) + return success + + +def get_git_root() -> Optional[Path]: + """Git 저장소 루트 경로 반환""" + success, stdout, _ = run_git_command(['rev-parse', '--show-toplevel'], check=False) + if success and stdout: + return Path(stdout) + return None + + +def get_current_branch() -> Optional[str]: + """현재 체크아웃된 브랜치명 반환""" + success, stdout, _ = run_git_command(['branch', '--show-current'], check=False) + if success and stdout: + return stdout + return None + + +def branch_exists(branch_name: str) -> bool: + """ + 브랜치 존재 여부 확인 + + Args: + branch_name: 확인할 브랜치명 + + Returns: + bool: 브랜치가 존재하면 True + """ + success, stdout, _ = run_git_command(['branch', '--list', branch_name], check=False) + if success and stdout: + # 출력 형식: " branch_name" 또는 "* branch_name" + branches = [line.strip().lstrip('* ') for line in stdout.split('\n')] + return branch_name in branches + return False + + +def create_branch(branch_name: str) -> bool: + """ + 현재 브랜치에서 새 브랜치 생성 + + Args: + branch_name: 생성할 브랜치명 + + Returns: + bool: 성공 여부 + """ + success, _, stderr = run_git_command(['branch', branch_name], check=False) + if not success: + print_error(f"브랜치 생성 실패: {stderr}") + return success + + +def get_worktree_list() -> Dict[str, str]: + """ + 현재 등록된 worktree 목록 반환 + + Returns: + Dict[str, str]: {worktree_path: branch_name} + """ + success, stdout, _ = run_git_command(['worktree', 'list', '--porcelain'], check=False) + if not success: + return {} + + worktrees = {} + current_path = None + + for line in stdout.split('\n'): + if line.startswith('worktree '): + current_path = line.replace('worktree ', '') + elif line.startswith('branch '): + branch = line.replace('branch ', '').replace('refs/heads/', '') + if current_path: + worktrees[current_path] = branch + current_path = None + + return worktrees + + +def prune_worktrees() -> bool: + """ + 유효하지 않은 worktree 정리 (git worktree prune) + + Returns: + bool: 성공 여부 + """ + success, _, stderr = run_git_command(['worktree', 'prune'], check=False) + if not success: + print_warning(f"Worktree prune 실패: {stderr}") + return success + + +def is_worktree_exists(worktree_path: Path) -> bool: + """ + 특정 경로에 worktree가 이미 존재하는지 확인 + + Git worktree 목록과 실제 디렉토리 존재 여부를 모두 확인합니다. + prunable 상태의 worktree는 자동으로 정리합니다. + + Args: + worktree_path: 확인할 worktree 경로 + + Returns: + bool: worktree가 유효하게 존재하면 True + """ + # 먼저 prunable worktree 정리 + prune_worktrees() + + worktrees = get_worktree_list() + worktree_path_resolved = worktree_path.resolve() + + for path in worktrees.keys(): + if Path(path).resolve() == worktree_path_resolved: + # Git 목록에 있으면 실제 디렉토리도 존재하는지 확인 + if Path(path).exists(): + return True + else: + # 디렉토리가 없으면 다시 prune 실행 + print_warning(f"Worktree 경로가 존재하지 않아 정리합니다: {path}") + prune_worktrees() + return False + + # 디렉토리만 존재하고 Git에 등록되지 않은 경우도 확인 + if worktree_path_resolved.exists(): + # .git 파일이 있는지 확인 (worktree의 특징) + git_file = worktree_path_resolved / '.git' + if git_file.exists(): + print_warning(f"디렉토리가 존재하지만 Git에 등록되지 않음: {worktree_path}") + return True + + return False + + +def create_worktree(branch_name: str, worktree_path: Path) -> Dict: + """ + Git worktree 생성 + + Args: + branch_name: 체크아웃할 브랜치명 + worktree_path: worktree를 생성할 경로 + + Returns: + Dict: { + 'success': bool, + 'path': str, + 'message': str, + 'is_existing': bool + } + """ + # 이미 존재하는지 확인 + if is_worktree_exists(worktree_path): + return { + 'success': True, + 'path': str(worktree_path.resolve()), + 'message': 'Worktree가 이미 존재합니다.', + 'is_existing': True + } + + # worktree 생성 + success, stdout, stderr = run_git_command( + ['worktree', 'add', str(worktree_path), branch_name], + check=False + ) + + if success: + return { + 'success': True, + 'path': str(worktree_path.resolve()), + 'message': 'Worktree 생성 완료!', + 'is_existing': False + } + else: + return { + 'success': False, + 'path': str(worktree_path.resolve()), + 'message': f'Worktree 생성 실패: {stderr}', + 'is_existing': False + } + + +# =================================================================== +# 경로 관련 함수 +# =================================================================== + +def normalize_branch_name(branch_name: str) -> str: + """ + 브랜치명을 폴더명으로 안전하게 변환 + + 특수문자 (#, /, \\\\, :, *, ?, ", <, >, |)를 _ 로 변환하고, + 연속된 _를 하나로 통합하며, 앞뒤 _를 제거합니다. + + Args: + branch_name: 원본 브랜치명 + + Returns: + str: 정규화된 폴더명 + + Example: + >>> normalize_branch_name("20260120_#163_Github_Projects") + "20260120_163_Github_Projects" + """ + # 특수문자를 _ 로 변환 + normalized = re.sub(SPECIAL_CHARS_PATTERN, '_', branch_name) + + # 연속된 _를 하나로 통합 + normalized = re.sub(r'_+', '_', normalized) + + # 앞뒤 _를 제거 + normalized = normalized.strip('_') + + return normalized + + +def get_worktree_root() -> Path: + """ + Worktree 루트 경로 계산 + + 현재 Git 저장소의 부모 디렉토리에 {프로젝트명}-Worktree 폴더 생성 + + Returns: + Path: Worktree 루트 경로 + + Example: + 현재: /Users/.../project/RomRom-FE + 반환: /Users/.../project/RomRom-FE-Worktree + """ + git_root = get_git_root() + if not git_root: + raise RuntimeError("Git 저장소 루트를 찾을 수 없습니다.") + + # 현재 Git 저장소의 이름 추출 (예: RomRom-FE) + project_name = git_root.name + + # 부모 디렉토리에 {프로젝트명}-Worktree 폴더 생성 + worktree_root_name = f"{project_name}-Worktree" + worktree_root = git_root.parent / worktree_root_name + + return worktree_root + + +def get_worktree_path(branch_name: str) -> Path: + """ + 특정 브랜치의 worktree 전체 경로 반환 + + Args: + branch_name: 브랜치명 (정규화 전) + + Returns: + Path: Worktree 경로 + + Example: + >>> get_worktree_path("20260120_#163_Github_Projects") + Path("/Users/.../project/RomRom-FE-Worktree/20260120_163_Github_Projects") + """ + worktree_root = get_worktree_root() + folder_name = normalize_branch_name(branch_name) + return worktree_root / folder_name + + +def ensure_directory(path: Path) -> bool: + """ + 디렉토리가 존재하지 않으면 생성 + + Args: + path: 생성할 디렉토리 경로 + + Returns: + bool: 성공 여부 + """ + try: + path.mkdir(parents=True, exist_ok=True) + return True + except Exception as e: + print_error(f"디렉토리 생성 실패: {e}") + return False + + +# =================================================================== +# 메인 워크플로우 +# =================================================================== + +def main() -> int: + """ + 메인 워크플로우 + + Returns: + int: Exit code (0: 성공, 1: 실패) + """ + print_header() + + # 1. 브랜치명 받기 (Windows 환경 대응) + branch_name = get_branch_name() + + if not branch_name: + print_error("브랜치명이 제공되지 않았습니다.") + print() + print("사용법:") + if IS_WINDOWS: + print(" Windows 환경:") + print(" 방법 1: 환경 변수 사용") + print(f' $env:GIT_BRANCH_NAME = "브랜치명"') + print(f" python -X utf8 {sys.argv[0]}") + print() + print(" 방법 2: 인자로 전달") + print(f' python -X utf8 {sys.argv[0]} "브랜치명"') + else: + print(f" python {sys.argv[0]} ") + print() + print("예시:") + print(f' python {sys.argv[0]} "20260120_#163_Github_Projects_에_대한_템플릿_개발_필요"') + return 1 + + print_step("📋", f"입력된 브랜치: {branch_name}") + + # 2. Git 저장소 확인 + if not is_git_repository(): + print_error("현재 디렉토리가 Git 저장소가 아닙니다.") + return 1 + + # 2-1. Windows 긴 경로 지원 확인 및 활성화 + if IS_WINDOWS: + check_and_enable_longpaths() + print() + + # 3. 브랜치명 정규화 + folder_name = normalize_branch_name(branch_name) + print_step("📁", f"폴더명: {folder_name}") + print() + + # 4. 브랜치 존재 확인 + print_step("🔍", "브랜치 확인 중...") + + if not branch_exists(branch_name): + print_warning("브랜치가 존재하지 않습니다.") + + current_branch = get_current_branch() + if current_branch: + print_step("🔄", f"현재 브랜치({current_branch})에서 새 브랜치 생성 중...") + else: + print_step("🔄", "새 브랜치 생성 중...") + + if not create_branch(branch_name): + print_error("브랜치 생성에 실패했습니다.") + return 1 + + print_success("브랜치 생성 완료!") + else: + print_success("브랜치가 이미 존재합니다.") + + print() + + # 5. Worktree 경로 계산 + try: + worktree_path = get_worktree_path(branch_name) + except RuntimeError as e: + print_error(str(e)) + return 1 + + print_step("📂", f"Worktree 경로: {worktree_path}") + print() + + # 6. Worktree 존재 확인 + print_step("🔍", "Worktree 확인 중...") + + if is_worktree_exists(worktree_path): + print_info("Worktree가 이미 존재합니다.") + print() + print_step("📍", f"경로: {worktree_path.resolve()}") + return 0 + + # 7. Worktree 루트 디렉토리 생성 + worktree_root = get_worktree_root() + if not ensure_directory(worktree_root): + return 1 + + # 8. Worktree 생성 + print_step("🔄", "Worktree 생성 중...") + + result = create_worktree(branch_name, worktree_path) + + if result['success']: + if result['is_existing']: + print_info(result['message']) + else: + print_success(result['message']) + + print() + print_step("📍", f"경로: {result['path']}") + return 0 + else: + print_error(result['message']) + return 1 + + +# =================================================================== +# 엔트리 포인트 +# =================================================================== + +if __name__ == "__main__": + try: + exit_code = main() + sys.exit(exit_code) + except KeyboardInterrupt: + print() + print_warning("사용자에 의해 중단되었습니다.") + sys.exit(130) + except Exception as e: + print() + print_error(f"예상치 못한 오류가 발생했습니다: {e}") + sys.exit(1) diff --git a/.cursor/commands/init-workflow.md b/.cursor/commands/init-workflow.md new file mode 100644 index 0000000..c9ff9d6 --- /dev/null +++ b/.cursor/commands/init-workflow.md @@ -0,0 +1,167 @@ +# init-workflow + +Git worktree를 자동으로 생성하는 커맨드입니다. + +브랜치명을 입력받아 자동으로: +1. 브랜치명은 사용자 입력 그대로 사용 (예: `20260127_#16_...`) +2. 브랜치가 없으면 생성 (현재 브랜치에서 분기) +3. 폴더명만 특수문자(`#` 포함)를 `_`로 변환하여 생성 (예: `20260127_16_...`) +4. `{프로젝트명}-Worktree` 폴더에 worktree 생성 (예: `RomRom-FE-Worktree`) +5. 설정 파일 자동 복사 (Firebase, iOS, Android 키 등) +6. 이미 존재하면 경로만 출력 + +## 사용법 + +``` +/init-workflow + +20260120_#163_Github_Projects_에_대한_템플릿_개발_필요_및_관련_Sync_워크플로우_개발_필요 +``` + +## 실행 로직 + +1. 사용자 입력에서 두 번째 줄의 브랜치명 추출 +2. 브랜치명은 그대로 사용 (임시 Python 스크립트에 포함) +3. 임시 Python 스크립트 파일 생성 (인코딩 문제 해결) +4. Python 스크립트 실행 (worktree 생성 + 설정 파일 복사) +5. 임시 파일 자동 삭제 +6. 결과 출력 + +--- + +사용자 입력에서 두 번째 줄을 추출하여 브랜치명으로 사용하세요. + +브랜치명이 제공되지 않은 경우: +- 사용법을 안내하세요. + +브랜치명이 제공된 경우: +1. 프로젝트 루트로 이동 +2. Git 긴 경로 지원 활성화: `git config --global core.longpaths true` (최초 1회만 실행) +3. 브랜치명은 사용자 입력 그대로 사용 (예: `20260116_#432_...` 그대로 유지) +4. 임시 Python 스크립트 파일 생성: + - 파일명: `init_worktree_temp_{timestamp}.py` + - 브랜치명을 코드에 직접 포함 (인코딩 문제 해결) + - worktree 생성 로직 포함 +5. **Python 스크립트 실행** (Windows에서는 `-X utf8` 플래그 필수): + ```bash + python -X utf8 init_worktree_temp_{timestamp}.py + ``` +6. 임시 파일 삭제 +7. 결과 출력 +8. 에이전트가 `.gitignore` 분석 후 민감 파일 복사 + +**중요**: +- **브랜치명 처리**: 브랜치명은 사용자 입력 그대로 사용됩니다 (`#` 문자 포함) +- **폴더명 처리**: 폴더명만 `normalize_branch_name()` 함수로 특수문자(`#` 포함)를 `_`로 변환합니다 +- **인코딩 문제 해결**: Python 스크립트 파일에 브랜치명을 직접 포함시켜 Windows PowerShell 인코딩 문제 회피 +- **Windows UTF-8 모드**: Python 실행 시 `-X utf8` 플래그 사용 필수 +- **설정 파일 자동 복사**: worktree 생성 후 에이전트가 동적으로 파일 복사 +- **플랫폼 독립성**: Windows/macOS/Linux 모두 동일한 방식으로 처리 + +**실행 예시**: +```powershell +# Windows PowerShell +cd d:\0-suh\project\RomRom-FE +git config --global core.longpaths true + +# Python UTF-8 모드로 실행 (Windows 한글 인코딩 문제 해결) +python -X utf8 init_worktree_temp.py + +# 입력 브랜치명: 20260116_#432_UX_개선_및_페이지_디자인_수정 +# → Git 브랜치: 20260116_#432_UX_개선_및_페이지_디자인_수정 (# 유지) +# → 폴더명: 20260116_432_UX_개선_및_페이지_디자인_수정 (# → _ 변환) +``` + +**Python 스크립트 구조**: +```python +# -*- coding: utf-8 -*- +import sys +import os +import shutil +import glob + +# 프로젝트 루트로 이동 +os.chdir('프로젝트_루트_경로') + +# 브랜치명 (사용자 입력 그대로) +branch_name = '20260116_#432_UX_개선_및_페이지_디자인_수정' + +# worktree_manager 실행 +sys.path.insert(0, '.cursor/scripts') +import worktree_manager +os.environ['GIT_BRANCH_NAME'] = branch_name +os.environ['PYTHONIOENCODING'] = 'utf-8' +sys.argv = ['worktree_manager.py'] +exit_code = worktree_manager.main() + +# worktree 경로를 환경변수로 설정 (에이전트가 파일 복사에 사용) +if exit_code == 0: + import subprocess + result = subprocess.run(['git', 'worktree', 'list', '--porcelain'], + capture_output=True, text=True, encoding='utf-8') + lines = result.stdout.split('\n') + worktree_path = None + for i, line in enumerate(lines): + if line.startswith(f'branch refs/heads/{branch_name}'): + worktree_path = lines[i-1].replace('worktree ', '') + break + + if worktree_path: + print(f'📍 WORKTREE_PATH={worktree_path}') + +sys.exit(exit_code) +``` + +## 설정 파일 복사 (에이전트 동적 판단) + +Worktree 생성 성공 후, **에이전트가 `.gitignore`를 분석하여 민감 파일을 동적으로 판단**하고 복사합니다. + +### Step 1: .gitignore 분석 + +프로젝트 `.gitignore` 파일을 읽고 다음 카테고리의 민감 파일 패턴을 식별합니다: + +| 카테고리 | 식별 패턴 | 설명 | +|---------|----------|------| +| Firebase 설정 | `google-services.json`, `GoogleService-Info.plist` | Firebase 연동 설정 | +| 서명 키/인증서 | `key.properties`, `*.jks`, `*.p12`, `*.p8`, `*.mobileprovision` | 앱 서명 인증서 | +| 빌드 설정 | `Secrets.xcconfig`, 민감한 `*.xcconfig` | iOS 빌드 비밀 설정 | +| 환경 변수 | `*.env` | 환경별 설정 파일 | +| IDE 로컬 설정 | `settings.local.json` | Claude/Cursor 로컬 설정 | + +### Step 2: 실제 파일 확인 및 복사 + +1. `.gitignore`에 명시된 패턴 중 **실제 존재하는 파일** 확인 +2. 존재하는 파일만 worktree 경로로 복사 +3. 디렉토리 구조 유지 (예: `android/app/google-services.json` → `worktree/android/app/google-services.json`) + +**복사 명령 예시**: +```bash +# Python shutil 사용 +import shutil +shutil.copy2('원본경로', 'worktree경로/원본경로') +``` + +### Step 3: 복사 제외 대상 (절대 복사 금지) + +다음은 민감 파일이더라도 **절대 복사하지 않습니다**: + +| 경로/패턴 | 이유 | +|----------|------| +| `build/`, `target/`, `.gradle/` | 빌드 산출물 (새로 빌드 필요) | +| `node_modules/`, `Pods/`, `.dart_tool/` | 의존성 (새로 설치 필요) | +| `.report/`, `.run/` | 보고서 (worktree별로 별도 생성) | +| `.idea/` | IDE 캐시 전체 | +| `*.log`, `*.class`, `*.pyc` | 임시/컴파일 파일 | + +### Step 4: 결과 출력 + +복사된 파일 목록을 ✅ 이모지와 함께 출력합니다: +``` +✅ android/app/google-services.json 복사 완료 +✅ ios/Runner/GoogleService-Info.plist 복사 완료 +✅ android/key.properties 복사 완료 +``` + +**참고**: +- 파일이 존재하지 않으면 해당 복사는 자동으로 건너뜁니다. +- 에이전트가 `.gitignore`를 분석하여 복사 대상을 동적으로 결정합니다. diff --git a/.cursor/commands/report.md b/.cursor/commands/report.md index 3172643..9055108 100644 --- a/.cursor/commands/report.md +++ b/.cursor/commands/report.md @@ -12,6 +12,52 @@ - ✅ 특이한 부분이나 주의할 점은 간단히 설명 - ✅ 민감 정보 마스킹: API Key, Password, Token, Secret 등은 반드시 마스킹 처리 +## ⛔ 금지 사항 (필수) + +보고서 작성 시 다음 내용은 **절대 포함하지 않음**: + +### 절대 사용 금지 필드 (메타 정보) +다음 필드는 어떤 형태로든 보고서에 포함하지 않음: + +- ❌ `**작성자**:` / `**작업자**:` / `**담당자**:` 필드 +- ❌ `**작성일**:` / `**작업일**:` / `**날짜**:` 필드 (보고서 본문에서) +- ❌ `## 작성 정보` 같은 메타 정보 섹션 +- ❌ 보고서 상단에 이슈 링크 + 날짜 + 작성자 조합 + +### 절대 사용 금지 단어/표현 +- ❌ `Claude`, `Claude Code`, `claude-opus`, `claude-sonnet` 등 AI 이름 +- ❌ `Cursor`, `Copilot`, `GPT`, `ChatGPT` 등 AI 도구명 +- ❌ `AI`, `인공지능`, `자동 생성`, `자동 작성` 등의 표현 +- ❌ `Co-Authored-By: Claude` 같은 서명 +- ❌ 모델명 (Sonnet, Opus, GPT-4 등) + +### 잘못된 예시 (절대 사용 금지) +```markdown +## 작성 정보 +- **작성일**: 2026-01-20 +- **작성자**: Claude Code (claude-opus-4-5-20251101) + +**이슈**: [#152](...) +**작성일**: 2026-01-12 +**작성자**: Claude Code + +**이슈**: [#145](...) +**작업일**: 2026-01-12 +**작업자**: Claude Code +``` + +### 올바른 예시 +보고서는 **작업 내용만** 포함하고, 메타 정보(작성자/작성일)는 완전히 제외: +```markdown +### 📌 작업 개요 +[작업 내용 설명] + +### ✅ 구현 내용 +[구현 내용...] +``` + +**핵심**: 보고서에는 **작성자/작성일 관련 필드를 절대 포함하지 않음**. 파일명에 날짜가 포함되므로 별도 기록 불필요. + ## 🔒 민감 정보 마스킹 (필수) 보고서 작성 시 다음 민감 정보는 반드시 마스킹 처리: diff --git a/.cursor/scripts/README.md b/.cursor/scripts/README.md index fcae808..9e558be 100644 --- a/.cursor/scripts/README.md +++ b/.cursor/scripts/README.md @@ -24,6 +24,57 @@ Cursor command에서 PowerShell 명령어 실행 시 오류가 발생하면, 이 ## 📦 포함된 모듈 +### `worktree_manager.py` (v1.0.0) 🆕 + +Git worktree를 자동으로 생성하고 관리하는 스크립트입니다. + +#### 기능 +- 브랜치가 없으면 자동 생성 (현재 브랜치에서 분기) +- 브랜치명의 특수문자(`#`, `/`, `\` 등)를 안전하게 처리 +- `RomRom-Worktree` 폴더에 worktree 자동 생성 +- 이미 존재하는 worktree는 건너뛰고 경로만 출력 + +#### 사용법 + +**Cursor Command로 실행 (권장):** + +``` +/init-workflow + +20260120_#163_Github_Projects_에_대한_템플릿_개발_필요 +``` + +**직접 실행:** + +```bash +python .cursor/scripts/worktree_manager.py "20260120_#163_Github_Projects_에_대한_템플릿_개발_필요" +``` + +#### 출력 예시 + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🌿 Git Worktree Manager v1.0.0 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +📋 입력된 브랜치: 20260120_#163_Github_Projects_에_대한_템플릿_개발_필요 +📁 폴더명: 20260120_163_Github_Projects_에_대한_템플릿_개발_필요 + +🔍 브랜치 확인 중... +⚠️ 브랜치가 존재하지 않습니다. +🔄 현재 브랜치(main)에서 새 브랜치 생성 중... +✅ 브랜치 생성 완료! + +📂 Worktree 경로: /Users/.../project/RomRom-Worktree/20260120_163_Github_Projects_에_대한_템플릿_개발_필요 + +🔄 Worktree 생성 중... +✅ Worktree 생성 완료! + +📍 경로: /Users/.../project/RomRom-Worktree/20260120_163_Github_Projects_에_대한_템플릿_개발_필요 +``` + +--- + ### `common_util.py` (v1.0.0) 한글 경로 처리 등 공통 기능을 제공하는 유틸리티 모듈입니다. diff --git a/.cursor/scripts/worktree_manager.py b/.cursor/scripts/worktree_manager.py new file mode 100644 index 0000000..fe2dbe0 --- /dev/null +++ b/.cursor/scripts/worktree_manager.py @@ -0,0 +1,644 @@ +# -*- coding: utf-8 -*- +""" +Git Worktree Manager v1.0.4 + +Git worktree를 자동으로 생성하고 관리하는 스크립트입니다. +브랜치가 없으면 자동으로 생성하고, 브랜치명의 특수문자를 안전하게 처리합니다. + +사용법: + macOS/Linux: + python worktree_manager.py + + Windows (환경 변수 방식, 권장): + $env:GIT_BRANCH_NAME = "브랜치명" + $env:PYTHONIOENCODING = "utf-8" + python -X utf8 worktree_manager.py + +예시: + python worktree_manager.py "20260120_#163_Github_Projects_에_대한_템플릿_개발_필요" + +Author: Cursor AI Assistant +Version: 1.0.4 +""" + +import os +import sys +import subprocess +import re +import platform +import io +from pathlib import Path +from typing import Dict, Optional, Tuple + +# Windows 인코딩 문제 해결 - stdout/stderr를 UTF-8로 래핑 +if platform.system() == 'Windows': + try: + # stdout/stderr가 버퍼를 가지고 있는 경우에만 래핑 + if hasattr(sys.stdout, 'buffer'): + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + if hasattr(sys.stderr, 'buffer'): + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') + except Exception: + pass # 래핑 실패 시 무시 + + +# =================================================================== +# 상수 정의 +# =================================================================== + +VERSION = "1.0.4" + +# Windows 환경 감지 +IS_WINDOWS = platform.system() == 'Windows' + +# 폴더명에서 제거할 특수문자 (파일시스템에서 안전하지 않은 문자) +SPECIAL_CHARS_PATTERN = r'[#/\\:*?"<>|]' + +# Worktree 루트 폴더명 (동적으로 설정됨) +# 예: RomRom-FE → RomRom-FE-Worktree +WORKTREE_ROOT_NAME = None # get_worktree_root()에서 동적으로 설정 + + +# =================================================================== +# 유틸리티 함수 +# =================================================================== + +def get_branch_name() -> str: + """ + 브랜치명을 안전하게 받기 (Windows 인코딩 문제 해결) + + Windows 환경에서 PowerShell → Python 스크립트로 한글 브랜치명을 전달할 때 + 인코딩 문제가 발생하므로, 환경 변수나 파일에서 읽는 방식을 우선 사용합니다. + + Returns: + str: 브랜치명 (비어있을 수 있음) + """ + if IS_WINDOWS: + # 방법 1: 환경 변수에서 읽기 (가장 간단하고 안전) + # Windows에서 환경 변수는 시스템 기본 인코딩을 사용하므로 UTF-8로 디코딩 시도 + branch_name_raw = os.environ.get('GIT_BRANCH_NAME', '') + if branch_name_raw: + try: + # 환경 변수가 이미 올바른 인코딩인 경우 + branch_name = branch_name_raw.strip() + # 한글이 깨져있는지 확인 (깨진 경우 복구 시도) + if '\xef\xbf\xbd' in branch_name.encode('utf-8', errors='replace').decode('utf-8', errors='replace'): + # 깨진 경우, 시스템 인코딩으로 디코딩 후 UTF-8로 재인코딩 시도 + import locale + sys_encoding = locale.getpreferredencoding() + branch_name = branch_name_raw.encode(sys_encoding, errors='replace').decode('utf-8', errors='replace').strip() + else: + branch_name = branch_name.strip() + if branch_name: + return branch_name + except Exception: + # 인코딩 변환 실패 시 원본 사용 + branch_name = branch_name_raw.strip() + if branch_name: + return branch_name + + # 방법 2: 임시 파일에서 읽기 (init-workflow에서 파일 생성 후 전달) + temp_file = os.environ.get('BRANCH_NAME_FILE', '') + if temp_file and os.path.exists(temp_file): + try: + # 여러 인코딩 시도: UTF-8, UTF-8 with BOM, 시스템 기본 인코딩 + encodings = ['utf-8', 'utf-8-sig', 'cp949', 'euc-kr'] + branch_name = None + for encoding in encodings: + try: + with open(temp_file, 'r', encoding=encoding) as f: + branch_name = f.read().strip() + if branch_name and not any(ord(c) > 0xFFFF for c in branch_name if ord(c) > 0x7F): + # 한글이 제대로 읽혔는지 확인 (깨진 문자가 없는지) + break + except (UnicodeDecodeError, UnicodeError): + continue + + if branch_name: + return branch_name + except Exception as e: + print_warning(f"브랜치명 파일 읽기 실패: {e}") + + # 방법 3: stdin에서 읽기 시도 (파이프 입력인 경우) + if not sys.stdin.isatty(): + try: + branch_name = sys.stdin.read().strip() + if branch_name: + return branch_name + except Exception: + pass + + # 기본: sys.argv에서 받기 (macOS/Linux 또는 Windows에서도 인자로 전달된 경우) + if len(sys.argv) >= 2: + return sys.argv[1].strip() + + return '' + + +def print_header(): + """헤더 출력""" + print("━" * 60) + print(f"🌿 Git Worktree Manager v{VERSION}") + print("━" * 60) + print() + + +def print_step(emoji: str, message: str): + """단계별 메시지 출력""" + print(f"{emoji} {message}") + + +def print_error(message: str): + """에러 메시지 출력""" + print(f"❌ 에러: {message}", file=sys.stderr) + + +def print_success(message: str): + """성공 메시지 출력""" + print(f"✅ {message}") + + +def print_info(message: str): + """정보 메시지 출력""" + print(f"ℹ️ {message}") + + +def print_warning(message: str): + """경고 메시지 출력""" + print(f"⚠️ {message}") + + +# =================================================================== +# Git 관련 함수 +# =================================================================== + +def run_git_command(args: list, check: bool = True) -> Tuple[bool, str, str]: + """ + Git 명령어 실행 + + Args: + args: Git 명령어 인자 리스트 (예: ['branch', '--list']) + check: 에러 발생 시 예외를 발생시킬지 여부 + + Returns: + Tuple[bool, str, str]: (성공 여부, stdout, stderr) + """ + try: + result = subprocess.run( + ['git'] + args, + capture_output=True, + text=True, + encoding='utf-8', + check=check + ) + return True, result.stdout.strip(), result.stderr.strip() + except subprocess.CalledProcessError as e: + return False, e.stdout.strip() if e.stdout else "", e.stderr.strip() if e.stderr else "" + except Exception as e: + return False, "", str(e) + + +def check_and_enable_longpaths() -> bool: + """ + Windows에서 Git 긴 경로 지원 확인 및 활성화 + + Returns: + bool: 긴 경로 지원이 활성화되어 있으면 True + """ + if not IS_WINDOWS: + return True + + # 현재 설정 확인 + success, stdout, _ = run_git_command(['config', '--global', 'core.longpaths'], check=False) + if success and stdout.strip().lower() == 'true': + return True + + # 긴 경로 지원 활성화 + print_info("Windows 긴 경로 지원을 활성화합니다...") + success, _, stderr = run_git_command(['config', '--global', 'core.longpaths', 'true'], check=False) + if success: + print_success("긴 경로 지원이 활성화되었습니다.") + return True + else: + print_warning(f"긴 경로 지원 활성화 실패: {stderr}") + print_warning("수동으로 실행하세요: git config --global core.longpaths true") + return False + + +def is_git_repository() -> bool: + """현재 디렉토리가 Git 저장소인지 확인""" + success, _, _ = run_git_command(['rev-parse', '--git-dir'], check=False) + return success + + +def get_git_root() -> Optional[Path]: + """Git 저장소 루트 경로 반환""" + success, stdout, _ = run_git_command(['rev-parse', '--show-toplevel'], check=False) + if success and stdout: + return Path(stdout) + return None + + +def get_current_branch() -> Optional[str]: + """현재 체크아웃된 브랜치명 반환""" + success, stdout, _ = run_git_command(['branch', '--show-current'], check=False) + if success and stdout: + return stdout + return None + + +def branch_exists(branch_name: str) -> bool: + """ + 브랜치 존재 여부 확인 + + Args: + branch_name: 확인할 브랜치명 + + Returns: + bool: 브랜치가 존재하면 True + """ + success, stdout, _ = run_git_command(['branch', '--list', branch_name], check=False) + if success and stdout: + # 출력 형식: " branch_name" 또는 "* branch_name" + branches = [line.strip().lstrip('* ') for line in stdout.split('\n')] + return branch_name in branches + return False + + +def create_branch(branch_name: str) -> bool: + """ + 현재 브랜치에서 새 브랜치 생성 + + Args: + branch_name: 생성할 브랜치명 + + Returns: + bool: 성공 여부 + """ + success, _, stderr = run_git_command(['branch', branch_name], check=False) + if not success: + print_error(f"브랜치 생성 실패: {stderr}") + return success + + +def get_worktree_list() -> Dict[str, str]: + """ + 현재 등록된 worktree 목록 반환 + + Returns: + Dict[str, str]: {worktree_path: branch_name} + """ + success, stdout, _ = run_git_command(['worktree', 'list', '--porcelain'], check=False) + if not success: + return {} + + worktrees = {} + current_path = None + + for line in stdout.split('\n'): + if line.startswith('worktree '): + current_path = line.replace('worktree ', '') + elif line.startswith('branch '): + branch = line.replace('branch ', '').replace('refs/heads/', '') + if current_path: + worktrees[current_path] = branch + current_path = None + + return worktrees + + +def prune_worktrees() -> bool: + """ + 유효하지 않은 worktree 정리 (git worktree prune) + + Returns: + bool: 성공 여부 + """ + success, _, stderr = run_git_command(['worktree', 'prune'], check=False) + if not success: + print_warning(f"Worktree prune 실패: {stderr}") + return success + + +def is_worktree_exists(worktree_path: Path) -> bool: + """ + 특정 경로에 worktree가 이미 존재하는지 확인 + + Git worktree 목록과 실제 디렉토리 존재 여부를 모두 확인합니다. + prunable 상태의 worktree는 자동으로 정리합니다. + + Args: + worktree_path: 확인할 worktree 경로 + + Returns: + bool: worktree가 유효하게 존재하면 True + """ + # 먼저 prunable worktree 정리 + prune_worktrees() + + worktrees = get_worktree_list() + worktree_path_resolved = worktree_path.resolve() + + for path in worktrees.keys(): + if Path(path).resolve() == worktree_path_resolved: + # Git 목록에 있으면 실제 디렉토리도 존재하는지 확인 + if Path(path).exists(): + return True + else: + # 디렉토리가 없으면 다시 prune 실행 + print_warning(f"Worktree 경로가 존재하지 않아 정리합니다: {path}") + prune_worktrees() + return False + + # 디렉토리만 존재하고 Git에 등록되지 않은 경우도 확인 + if worktree_path_resolved.exists(): + # .git 파일이 있는지 확인 (worktree의 특징) + git_file = worktree_path_resolved / '.git' + if git_file.exists(): + print_warning(f"디렉토리가 존재하지만 Git에 등록되지 않음: {worktree_path}") + return True + + return False + + +def create_worktree(branch_name: str, worktree_path: Path) -> Dict: + """ + Git worktree 생성 + + Args: + branch_name: 체크아웃할 브랜치명 + worktree_path: worktree를 생성할 경로 + + Returns: + Dict: { + 'success': bool, + 'path': str, + 'message': str, + 'is_existing': bool + } + """ + # 이미 존재하는지 확인 + if is_worktree_exists(worktree_path): + return { + 'success': True, + 'path': str(worktree_path.resolve()), + 'message': 'Worktree가 이미 존재합니다.', + 'is_existing': True + } + + # worktree 생성 + success, stdout, stderr = run_git_command( + ['worktree', 'add', str(worktree_path), branch_name], + check=False + ) + + if success: + return { + 'success': True, + 'path': str(worktree_path.resolve()), + 'message': 'Worktree 생성 완료!', + 'is_existing': False + } + else: + return { + 'success': False, + 'path': str(worktree_path.resolve()), + 'message': f'Worktree 생성 실패: {stderr}', + 'is_existing': False + } + + +# =================================================================== +# 경로 관련 함수 +# =================================================================== + +def normalize_branch_name(branch_name: str) -> str: + """ + 브랜치명을 폴더명으로 안전하게 변환 + + 특수문자 (#, /, \\, :, *, ?, ", <, >, |)를 _ 로 변환하고, + 연속된 _를 하나로 통합하며, 앞뒤 _를 제거합니다. + + Args: + branch_name: 원본 브랜치명 + + Returns: + str: 정규화된 폴더명 + + Example: + >>> normalize_branch_name("20260120_#163_Github_Projects") + "20260120_163_Github_Projects" + """ + # 특수문자를 _ 로 변환 + normalized = re.sub(SPECIAL_CHARS_PATTERN, '_', branch_name) + + # 연속된 _를 하나로 통합 + normalized = re.sub(r'_+', '_', normalized) + + # 앞뒤 _를 제거 + normalized = normalized.strip('_') + + return normalized + + +def get_worktree_root() -> Path: + """ + Worktree 루트 경로 계산 + + 현재 Git 저장소의 부모 디렉토리에 {프로젝트명}-Worktree 폴더 생성 + + Returns: + Path: Worktree 루트 경로 + + Example: + 현재: /Users/.../project/RomRom-FE + 반환: /Users/.../project/RomRom-FE-Worktree + """ + git_root = get_git_root() + if not git_root: + raise RuntimeError("Git 저장소 루트를 찾을 수 없습니다.") + + # 현재 Git 저장소의 이름 추출 (예: RomRom-FE) + project_name = git_root.name + + # 부모 디렉토리에 {프로젝트명}-Worktree 폴더 생성 + worktree_root_name = f"{project_name}-Worktree" + worktree_root = git_root.parent / worktree_root_name + + return worktree_root + + +def get_worktree_path(branch_name: str) -> Path: + """ + 특정 브랜치의 worktree 전체 경로 반환 + + Args: + branch_name: 브랜치명 (정규화 전) + + Returns: + Path: Worktree 경로 + + Example: + >>> get_worktree_path("20260120_#163_Github_Projects") + Path("/Users/.../project/RomRom-FE-Worktree/20260120_163_Github_Projects") + """ + worktree_root = get_worktree_root() + folder_name = normalize_branch_name(branch_name) + return worktree_root / folder_name + + +def ensure_directory(path: Path) -> bool: + """ + 디렉토리가 존재하지 않으면 생성 + + Args: + path: 생성할 디렉토리 경로 + + Returns: + bool: 성공 여부 + """ + try: + path.mkdir(parents=True, exist_ok=True) + return True + except Exception as e: + print_error(f"디렉토리 생성 실패: {e}") + return False + + +# =================================================================== +# 메인 워크플로우 +# =================================================================== + +def main() -> int: + """ + 메인 워크플로우 + + Returns: + int: Exit code (0: 성공, 1: 실패) + """ + print_header() + + # 1. 브랜치명 받기 (Windows 환경 대응) + branch_name = get_branch_name() + + if not branch_name: + print_error("브랜치명이 제공되지 않았습니다.") + print() + print("사용법:") + if IS_WINDOWS: + print(" Windows 환경:") + print(" 방법 1: 환경 변수 사용") + print(f' $env:GIT_BRANCH_NAME = "브랜치명"') + print(f" python {sys.argv[0]}") + print() + print(" 방법 2: 파일 사용") + print(f' $env:BRANCH_NAME_FILE = "branch_name.txt"') + print(f" python {sys.argv[0]}") + print() + print(" 방법 3: 인자로 전달 (한글 깨짐 가능)") + print(f' python {sys.argv[0]} "브랜치명"') + else: + print(f" python {sys.argv[0]} ") + print() + print("예시:") + print(f' python {sys.argv[0]} "20260120_#163_Github_Projects_에_대한_템플릿_개발_필요"') + return 1 + + print_step("📋", f"입력된 브랜치: {branch_name}") + + # 2. Git 저장소 확인 + if not is_git_repository(): + print_error("현재 디렉토리가 Git 저장소가 아닙니다.") + return 1 + + # 2-1. Windows 긴 경로 지원 확인 및 활성화 + if IS_WINDOWS: + check_and_enable_longpaths() + print() + + # 3. 브랜치명 정규화 + folder_name = normalize_branch_name(branch_name) + print_step("📁", f"폴더명: {folder_name}") + print() + + # 4. 브랜치 존재 확인 + print_step("🔍", "브랜치 확인 중...") + + if not branch_exists(branch_name): + print_warning("브랜치가 존재하지 않습니다.") + + current_branch = get_current_branch() + if current_branch: + print_step("🔄", f"현재 브랜치({current_branch})에서 새 브랜치 생성 중...") + else: + print_step("🔄", "새 브랜치 생성 중...") + + if not create_branch(branch_name): + print_error("브랜치 생성에 실패했습니다.") + return 1 + + print_success("브랜치 생성 완료!") + else: + print_success("브랜치가 이미 존재합니다.") + + print() + + # 5. Worktree 경로 계산 + try: + worktree_path = get_worktree_path(branch_name) + except RuntimeError as e: + print_error(str(e)) + return 1 + + print_step("📂", f"Worktree 경로: {worktree_path}") + print() + + # 6. Worktree 존재 확인 + print_step("🔍", "Worktree 확인 중...") + + if is_worktree_exists(worktree_path): + print_info("Worktree가 이미 존재합니다.") + print() + print_step("📍", f"경로: {worktree_path.resolve()}") + return 0 + + # 7. Worktree 루트 디렉토리 생성 + worktree_root = get_worktree_root() + if not ensure_directory(worktree_root): + return 1 + + # 8. Worktree 생성 + print_step("🔄", "Worktree 생성 중...") + + result = create_worktree(branch_name, worktree_path) + + if result['success']: + if result['is_existing']: + print_info(result['message']) + else: + print_success(result['message']) + + print() + print_step("📍", f"경로: {result['path']}") + return 0 + else: + print_error(result['message']) + return 1 + + +# =================================================================== +# 엔트리 포인트 +# =================================================================== + +if __name__ == "__main__": + try: + exit_code = main() + sys.exit(exit_code) + except KeyboardInterrupt: + print() + print_warning("사용자에 의해 중단되었습니다.") + sys.exit(130) + except Exception as e: + print() + print_error(f"예상치 못한 오류가 발생했습니다: {e}") + sys.exit(1) diff --git a/.github/workflows/PROJECT-COMMON-PROJECT-BACKLOG-MANAGER.yaml b/.github/workflows/PROJECT-COMMON-PROJECT-BACKLOG-MANAGER.yaml deleted file mode 100644 index be38dd0..0000000 --- a/.github/workflows/PROJECT-COMMON-PROJECT-BACKLOG-MANAGER.yaml +++ /dev/null @@ -1,526 +0,0 @@ -# =================================================================== -# GitHub Projects Backlog 자동 관리 워크플로우 -# =================================================================== -# -# 이 워크플로우는 GitHub Issue와 Projects를 자동으로 동기화합니다. -# -# 작동 방식: -# 1. Issue 생성 시 자동으로 프로젝트에 추가 (PR은 옵션) -# 2. Issue Label 변경 시 Projects Status 자동 동기화 -# 3. Issue 닫기 시 Projects Status를 "작업 완료"로 자동 변경 (PR은 옵션) -# -# 지원 기능: -# - Issue 생성 → 프로젝트 자동 추가 (기본 Status: "작업 전") -# - PR 생성 → 프로젝트 자동 추가 (옵션, 기본값: 비활성화) -# - Label 변경 → Projects Status 실시간 동기화 (Issue만 지원) -# • 작업 전 → 작업 전 -# • 작업 중 → 작업 중 -# • 확인 대기 → 확인 대기 -# • 피드백 → 피드백 -# • 작업 완료 → 작업 완료 -# • 취소 → 취소 -# - Issue 닫기 → "작업 완료" Status 자동 설정 -# - PR 닫기 → "작업 완료" Status 자동 설정 (옵션, 기본값: 비활성화) -# - 여러 Status Label 동시 존재 시 우선순위 정책 적용 -# -# Label 우선순위: -# 작업 완료 > 취소 > 피드백 > 확인 대기 > 작업 중 > 작업 전 -# -# 필수 설정: -# - Organization Secret: _GITHUB_PAT_TOKEN (프로젝트 추가용) -# - Organization Secret: UPDATE_PROJECT_V2_PAT (Status 업데이트용, Classic PAT 필요) -# • 필요 권한: repo (전체), project (read:project, write:project) -# • Fine-grained token은 GraphQL API 미지원 -# -# 환경변수 설정: -# - ENABLE_PR_AUTO_ADD: PR 생성 시 프로젝트 자동 추가 (기본값: false) -# - ENABLE_PR_AUTO_CLOSE: PR 닫기 시 작업 완료 처리 (기본값: false) -# -# 사용 예시: -# - Issue만 자동화: 기본 설정 사용 (변경 불필요) -# - PR도 자동화: env에서 ENABLE_PR_AUTO_ADD와 ENABLE_PR_AUTO_CLOSE를 true로 변경 -# -# =================================================================== - -name: PROJECT-BACKLOG-MANAGER - -on: - issues: - types: [opened, labeled, unlabeled, closed] - pull_request: - types: [opened, closed] - -# =================================================================== -# 설정 변수 -# =================================================================== -env: - PROJECT_URL: https://github.com/orgs/MapSee-Lab/projects/1 - STATUS_FIELD: Status - # PR 자동화 옵션 (기본값: false - Issue만 처리) - ENABLE_PR_AUTO_ADD: false # PR 생성 시 프로젝트 자동 추가 - ENABLE_PR_AUTO_CLOSE: false # PR 닫기 시 작업 완료 처리 - -permissions: - issues: write - pull-requests: write - contents: read - -jobs: - # =================================================================== - # Job 1: Issue/PR 생성 시 프로젝트에 자동 추가 - # =================================================================== - add-to-project: - name: 프로젝트에 Issue/PR 추가 - if: | - github.event.action == 'opened' && - ( - github.event_name == 'issues' || - (github.event_name == 'pull_request' && env.ENABLE_PR_AUTO_ADD == 'true') - ) - runs-on: ubuntu-latest - steps: - - name: 프로젝트에 Issue/PR 추가 - uses: actions/add-to-project@v0.5.0 - with: - project-url: ${{ env.PROJECT_URL }} - github-token: ${{ secrets._GITHUB_PAT_TOKEN }} - - - name: 추가 완료 로그 - run: | - if [ "${{ github.event_name }}" == "issues" ]; then - echo "✅ Issue가 프로젝트에 추가되었습니다." - echo " • 번호: #${{ github.event.issue.number }}" - else - echo "✅ PR이 프로젝트에 추가되었습니다. (ENABLE_PR_AUTO_ADD: true)" - echo " • 번호: #${{ github.event.pull_request.number }}" - fi - echo " • 프로젝트: ${{ env.PROJECT_URL }}" - - # =================================================================== - # Job 2: Label 변경 시 Projects Status 동기화 - # =================================================================== - sync-label-to-status: - name: Label을 Projects Status로 동기화 - if: | - github.event_name == 'issues' && - (github.event.action == 'labeled' || github.event.action == 'unlabeled') - runs-on: ubuntu-latest - steps: - - name: 현재 Issue의 모든 Label 조회 - id: get-labels - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log('📋 Label 조회 시작'); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - - const issue = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number - }); - - const labels = issue.data.labels.map(label => label.name); - console.log(`📌 Issue #${context.issue.number}의 현재 Labels:`); - console.log(` ${labels.join(', ') || '(없음)'}`); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - - return labels; - - - name: Status로 매핑할 Label 결정 - id: determine-status - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log('🎯 Status 결정 시작'); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - - const labels = ${{ steps.get-labels.outputs.result }}; - - // Status Label 우선순위 (높음 → 낮음) - const statusPriority = [ - '작업 완료', - '취소', - '피드백', - '확인 대기', - '작업 중', - '작업 전' - ]; - - // 현재 Label 중 Status Label 찾기 - let targetStatus = ''; - for (const status of statusPriority) { - if (labels.includes(status)) { - targetStatus = status; - console.log(`✅ Status Label 발견: "${targetStatus}"`); - break; - } - } - - if (!targetStatus) { - console.log('⚠️ Status Label이 없습니다. Status 업데이트 건너뜀'); - } else { - console.log(`🎯 Projects Status로 설정할 값: "${targetStatus}"`); - } - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - - return targetStatus; - - - name: Projects Status 업데이트 - if: steps.determine-status.outputs.result != '' - uses: actions/github-script@v7 - env: - TARGET_STATUS: ${{ steps.determine-status.outputs.result }} - PAT_TOKEN: ${{ secrets.UPDATE_PROJECT_V2_PAT }} - with: - github-token: ${{ secrets.UPDATE_PROJECT_V2_PAT }} - script: | - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log('🔄 Projects Status 업데이트 시작'); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - - const targetStatus = process.env.TARGET_STATUS; - const issueNodeId = context.payload.issue.node_id; - const projectUrl = '${{ env.PROJECT_URL }}'; - - console.log(`📌 Issue Node ID: ${issueNodeId}`); - console.log(`📌 목표 Status: "${targetStatus}"`); - console.log(`📌 프로젝트 URL: ${projectUrl}`); - - try { - // 1. 프로젝트 번호 추출 - const projectMatch = projectUrl.match(/\/projects\/(\d+)/); - if (!projectMatch) { - throw new Error('프로젝트 URL에서 번호를 추출할 수 없습니다.'); - } - const projectNumber = parseInt(projectMatch[1]); - console.log(`📊 프로젝트 번호: ${projectNumber}`); - - // 2. 조직 프로젝트 정보 조회 - const orgLogin = context.repo.owner; - console.log(`🏢 조직명: ${orgLogin}`); - - const projectQuery = ` - query($orgLogin: String!, $projectNumber: Int!) { - organization(login: $orgLogin) { - projectV2(number: $projectNumber) { - id - fields(first: 20) { - nodes { - ... on ProjectV2SingleSelectField { - id - name - options { - id - name - } - } - } - } - } - } - } - `; - - const projectData = await github.graphql(projectQuery, { - orgLogin, - projectNumber - }); - - const project = projectData.organization.projectV2; - const projectId = project.id; - console.log(`✅ 프로젝트 ID: ${projectId}`); - - // 3. Status 필드 및 옵션 ID 찾기 - const statusField = project.fields.nodes.find( - field => field.name === '${{ env.STATUS_FIELD }}' - ); - - if (!statusField) { - throw new Error('Status 필드를 찾을 수 없습니다.'); - } - - const fieldId = statusField.id; - console.log(`✅ Status 필드 ID: ${fieldId}`); - - const statusOption = statusField.options.find( - option => option.name === targetStatus - ); - - if (!statusOption) { - throw new Error(`"${targetStatus}" 옵션을 찾을 수 없습니다.`); - } - - const optionId = statusOption.id; - console.log(`✅ "${targetStatus}" 옵션 ID: ${optionId}`); - - // 4. Issue의 프로젝트 아이템 ID 조회 - const itemQuery = ` - query($issueId: ID!) { - node(id: $issueId) { - ... on Issue { - projectItems(first: 10) { - nodes { - id - project { - id - } - } - } - } - } - } - `; - - const itemData = await github.graphql(itemQuery, { - issueId: issueNodeId - }); - - const projectItem = itemData.node.projectItems.nodes.find( - item => item.project.id === projectId - ); - - if (!projectItem) { - console.log('⚠️ 이 Issue가 프로젝트에 추가되지 않았습니다.'); - console.log(' 프로젝트 추가 후 Label을 다시 변경해주세요.'); - return; - } - - const itemId = projectItem.id; - console.log(`✅ 프로젝트 아이템 ID: ${itemId}`); - - // 5. Status 업데이트 뮤테이션 실행 - const updateMutation = ` - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { - updateProjectV2ItemFieldValue( - input: { - projectId: $projectId - itemId: $itemId - fieldId: $fieldId - value: { singleSelectOptionId: $optionId } - } - ) { - projectV2Item { - id - } - } - } - `; - - await github.graphql(updateMutation, { - projectId, - itemId, - fieldId, - optionId - }); - - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log('🎉 Projects Status 업데이트 완료!'); - console.log(` • Issue: #${context.issue.number}`); - console.log(` • Status: "${targetStatus}"`); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - - } catch (error) { - console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.error('❌ Status 업데이트 실패'); - console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.error(error); - console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - throw error; - } - - # =================================================================== - # Job 3: Issue/PR 닫기 시 "작업 완료" Status로 변경 - # =================================================================== - move-closed-to-done: - name: 닫힌 Issue/PR을 작업 완료로 이동 - if: | - github.event.action == 'closed' && - ( - github.event_name == 'issues' || - (github.event_name == 'pull_request' && env.ENABLE_PR_AUTO_CLOSE == 'true') - ) - runs-on: ubuntu-latest - steps: - - name: Projects Status를 "작업 완료"로 업데이트 - uses: actions/github-script@v7 - env: - DONE_STATUS: 작업 완료 - PAT_TOKEN: ${{ secrets.UPDATE_PROJECT_V2_PAT }} - with: - github-token: ${{ secrets.UPDATE_PROJECT_V2_PAT }} - script: | - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log('🏁 Issue/PR 닫기 감지 - 작업 완료 처리'); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - - const targetStatus = process.env.DONE_STATUS; - const itemNodeId = context.payload.issue?.node_id || context.payload.pull_request?.node_id; - const itemNumber = context.payload.issue?.number || context.payload.pull_request?.number; - const itemType = context.payload.issue ? 'Issue' : 'Pull Request'; - const projectUrl = '${{ env.PROJECT_URL }}'; - - console.log(`📌 ${itemType} #${itemNumber}`); - console.log(`📌 Node ID: ${itemNodeId}`); - console.log(`📌 목표 Status: "${targetStatus}"`); - - try { - // 1. 프로젝트 번호 추출 - const projectMatch = projectUrl.match(/\/projects\/(\d+)/); - if (!projectMatch) { - throw new Error('프로젝트 URL에서 번호를 추출할 수 없습니다.'); - } - const projectNumber = parseInt(projectMatch[1]); - - // 2. 조직 프로젝트 정보 조회 - const orgLogin = context.repo.owner; - - const projectQuery = ` - query($orgLogin: String!, $projectNumber: Int!) { - organization(login: $orgLogin) { - projectV2(number: $projectNumber) { - id - fields(first: 20) { - nodes { - ... on ProjectV2SingleSelectField { - id - name - options { - id - name - } - } - } - } - } - } - } - `; - - const projectData = await github.graphql(projectQuery, { - orgLogin, - projectNumber - }); - - const project = projectData.organization.projectV2; - const projectId = project.id; - - // 3. Status 필드 및 "작업 완료" 옵션 ID 찾기 - const statusField = project.fields.nodes.find( - field => field.name === '${{ env.STATUS_FIELD }}' - ); - - if (!statusField) { - throw new Error('Status 필드를 찾을 수 없습니다.'); - } - - const fieldId = statusField.id; - - const doneOption = statusField.options.find( - option => option.name === targetStatus - ); - - if (!doneOption) { - throw new Error(`"${targetStatus}" 옵션을 찾을 수 없습니다.`); - } - - const optionId = doneOption.id; - console.log(`✅ "${targetStatus}" 옵션 ID: ${optionId}`); - - // 4. Issue/PR의 프로젝트 아이템 ID 조회 - const itemQuery = context.payload.issue - ? ` - query($itemId: ID!) { - node(id: $itemId) { - ... on Issue { - projectItems(first: 10) { - nodes { - id - project { - id - } - } - } - } - } - } - ` - : ` - query($itemId: ID!) { - node(id: $itemId) { - ... on PullRequest { - projectItems(first: 10) { - nodes { - id - project { - id - } - } - } - } - } - } - `; - - const itemData = await github.graphql(itemQuery, { - itemId: itemNodeId - }); - - const projectItem = itemData.node.projectItems.nodes.find( - item => item.project.id === projectId - ); - - if (!projectItem) { - console.log(`⚠️ 이 ${itemType}가 프로젝트에 추가되지 않았습니다.`); - console.log(' 자동 완료 처리를 건너뜁니다.'); - return; - } - - const itemId = projectItem.id; - console.log(`✅ 프로젝트 아이템 ID: ${itemId}`); - - // 5. Status 업데이트 뮤테이션 실행 - const updateMutation = ` - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { - updateProjectV2ItemFieldValue( - input: { - projectId: $projectId - itemId: $itemId - fieldId: $fieldId - value: { singleSelectOptionId: $optionId } - } - ) { - projectV2Item { - id - } - } - } - `; - - await github.graphql(updateMutation, { - projectId, - itemId, - fieldId, - optionId - }); - - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log('🎉 작업 완료 처리 성공!'); - console.log(` • ${itemType}: #${itemNumber}`); - console.log(` • Status: "${targetStatus}"`); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - - } catch (error) { - console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.error('❌ 작업 완료 처리 실패'); - console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.error(error); - console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - - // 실패해도 워크플로우는 성공으로 처리 (GitHub 기본 자동화가 있을 수 있음) - console.log('⚠️ GitHub Projects 기본 자동화를 확인하세요.'); - } diff --git a/.github/workflows/PROJECT-COMMON-PROJECTS-SYNC-MANAGER.yaml b/.github/workflows/PROJECT-COMMON-PROJECTS-SYNC-MANAGER.yaml new file mode 100644 index 0000000..e03b326 --- /dev/null +++ b/.github/workflows/PROJECT-COMMON-PROJECTS-SYNC-MANAGER.yaml @@ -0,0 +1,836 @@ +# =================================================================== +# GitHub Projects 자동 관리 워크플로우 +# =================================================================== +# +# 이 워크플로우는 GitHub Issue와 Projects를 자동으로 동기화합니다. +# +# 작동 방식: +# 1. Issue 생성 시 자동으로 프로젝트에 추가 (PR은 옵션) +# 2. Issue Label 변경 시 Projects Status 자동 동기화 +# 3. Issue 닫기 시 Projects Status를 "작업 완료"로 자동 변경 (PR은 옵션) +# +# 지원 기능: +# - Issue 생성 → 프로젝트 자동 추가 (기본 Status: "작업 전") +# - PR 생성 → 프로젝트 자동 추가 (옵션, 기본값: 비활성화) +# - Label 변경 → Projects Status 실시간 동기화 (Issue만 지원) +# • 작업 전 → 작업 전 +# • 작업 중 → 작업 중 +# • 확인 대기 → 확인 대기 +# • 피드백 → 피드백 +# • 작업 완료 → 작업 완료 +# • 취소 → 취소 +# - Issue 닫기 → "작업 완료" Status 자동 설정 +# - PR 닫기 → "작업 완료" Status 자동 설정 (옵션, 기본값: 비활성화) +# - 여러 Status Label 동시 존재 시 우선순위 정책 적용 +# +# Label 우선순위: +# 작업 완료 > 취소 > 피드백 > 확인 대기 > 작업 중 > 작업 전 +# +# 필수 설정: +# - Organization Secret: _GITHUB_PAT_TOKEN (모든 작업에 사용) +# • 필요 권한: repo (전체), project (read:project, write:project) +# • Classic PAT 필요 (Fine-grained token은 GraphQL API 미지원) +# +# 환경변수 설정: +# - PROJECT_URL: GitHub Projects URL (필수) +# - STATUS_FIELD: Projects의 Status 필드명 (기본값: "Status") +# - ENABLE_PR_AUTO_ADD: PR 생성 시 프로젝트 자동 추가 (기본값: false) +# - ENABLE_PR_AUTO_CLOSE: PR 닫기 시 작업 완료 처리 (기본값: false) +# - STATUS_PRIORITY: Status Label 우선순위 (JSON 배열, 커스터마이징 가능) +# - DONE_STATUS: Issue/PR 닫기 시 설정할 Status (기본값: "작업 완료") +# - DEFAULT_STATUS: Issue 생성 시 기본 Status (기본값: "작업 전", 선택적) +# +# 사용 예시: +# - Issue만 자동화: 기본 설정 사용 (변경 불필요) +# - PR도 자동화: ENABLE_PR_AUTO_ADD와 ENABLE_PR_AUTO_CLOSE를 true로 변경 +# - Status 우선순위 변경: STATUS_PRIORITY JSON 배열 수정 +# +# =================================================================== + +name: PROJECT-COMMON-PROJECTS-SYNC-MANAGER + +on: + issues: + types: [opened, labeled, unlabeled, closed] + pull_request: + types: [opened, closed] + +# =================================================================== +# 설정 변수 +# =================================================================== +env: + PROJECT_URL: https://github.com/orgs/MapSee-Lab/projects/1 + STATUS_FIELD: Status + + # PR 자동화 옵션 (기본값: false - Issue만 처리) + ENABLE_PR_AUTO_ADD: false # PR 생성 시 프로젝트 자동 추가 + ENABLE_PR_AUTO_CLOSE: false # PR 닫기 시 작업 완료 처리 + + # Status 관리 옵션 + STATUS_PRIORITY: '["작업 완료","취소","피드백","확인 대기","작업 중","작업 전"]' # Status Label 우선순위 (JSON 배열) + DONE_STATUS: "작업 완료" # Issue/PR 닫기 시 설정할 Status + DEFAULT_STATUS: "작업 전" # Issue 생성 시 기본 Status (선택적, actions/add-to-project가 자동 설정) + +permissions: + issues: write + pull-requests: write + contents: read + +jobs: + # =================================================================== + # Job 1: Issue/PR 생성 시 프로젝트에 자동 추가 + # =================================================================== + add-to-project: + name: 프로젝트에 Issue/PR 추가 + if: | + github.event.action == 'opened' && + ( + github.event_name == 'issues' || + (github.event_name == 'pull_request' && false) + ) + runs-on: ubuntu-latest + steps: + - name: 프로젝트에 Issue/PR 추가 + uses: actions/add-to-project@v0.5.0 + with: + project-url: ${{ env.PROJECT_URL }} + github-token: ${{ secrets._GITHUB_PAT_TOKEN }} + + - name: 추가 완료 로그 + run: | + if [ "${{ github.event_name }}" == "issues" ]; then + echo "✅ Issue가 프로젝트에 추가되었습니다." + echo " • 번호: #${{ github.event.issue.number }}" + else + echo "✅ PR이 프로젝트에 추가되었습니다. (ENABLE_PR_AUTO_ADD: true)" + echo " • 번호: #${{ github.event.pull_request.number }}" + fi + echo " • 프로젝트: ${{ env.PROJECT_URL }}" + + # =================================================================== + # Job 2: Label 변경 시 Projects Status 동기화 + # =================================================================== + sync-label-to-status: + name: Label을 Projects Status로 동기화 + if: | + github.event_name == 'issues' && + (github.event.action == 'labeled' || github.event.action == 'unlabeled') + runs-on: ubuntu-latest + steps: + - name: 현재 Issue의 모든 Label 조회 + id: get-labels + uses: actions/github-script@v7 + with: + github-token: ${{ secrets._GITHUB_PAT_TOKEN }} + script: | + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('📋 Label 조회 시작'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + const issue = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + + const labels = issue.data.labels.map(label => label.name); + console.log(`📌 Issue #${context.issue.number}의 현재 Labels:`); + console.log(` ${labels.join(', ') || '(없음)'}`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + return labels; + + - name: Status로 매핑할 Label 결정 + id: determine-status + uses: actions/github-script@v7 + env: + STATUS_PRIORITY: ${{ env.STATUS_PRIORITY }} + with: + github-token: ${{ secrets._GITHUB_PAT_TOKEN }} + script: | + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('🎯 Status 결정 시작'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + // ===== 1. Labels 배열 검증 및 정제 ===== + let labels = ${{ steps.get-labels.outputs.result }}; + + if (!Array.isArray(labels)) { + console.error('❌ labels가 배열이 아닙니다. 빈 배열로 대체합니다.'); + console.error(` 실제 타입: ${typeof labels}`); + console.error(` 실제 값: ${JSON.stringify(labels)}`); + labels = []; + } + + // 유효하지 않은 Label 필터링 (null, undefined, 빈 문자열, 비문자열 제거) + const originalLabelCount = labels.length; + labels = labels.filter(label => { + if (typeof label !== 'string') { + console.warn(`⚠️ 비문자열 Label 제거됨 (타입: ${typeof label}, 값: ${JSON.stringify(label)})`); + return false; + } + if (label.trim() === '') { + console.warn('⚠️ 빈 문자열 Label 제거됨'); + return false; + } + return true; + }); + + if (labels.length < originalLabelCount) { + console.log(`🧹 유효하지 않은 Label ${originalLabelCount - labels.length}개 제거됨`); + } + + console.log(`📌 유효한 Label 개수: ${labels.length}`); + + // ===== 2. Status 우선순위 배열 검증 및 정제 ===== + let statusPriority; + try { + const statusPriorityEnv = process.env.STATUS_PRIORITY || '["작업 완료","취소","피드백","확인 대기","작업 중","작업 전"]'; + statusPriority = JSON.parse(statusPriorityEnv); + + // 배열 타입 검증 + if (!Array.isArray(statusPriority)) { + throw new Error(`STATUS_PRIORITY must be an array (actual type: ${typeof statusPriority})`); + } + + // 빈 배열 검증 + if (statusPriority.length === 0) { + throw new Error('STATUS_PRIORITY must be a non-empty array'); + } + + // 각 요소가 유효한 문자열인지 검증 및 필터링 + const originalPriorityCount = statusPriority.length; + statusPriority = statusPriority.filter(item => { + if (typeof item !== 'string') { + console.warn(`⚠️ STATUS_PRIORITY에서 비문자열 요소 제거됨 (타입: ${typeof item}, 값: ${JSON.stringify(item)})`); + return false; + } + if (item.trim() === '') { + console.warn('⚠️ STATUS_PRIORITY에서 빈 문자열 요소 제거됨'); + return false; + } + return true; + }); + + // 필터링 후 빈 배열인지 재검증 + if (statusPriority.length === 0) { + throw new Error('STATUS_PRIORITY must contain at least one non-empty string'); + } + + // 유효하지 않은 요소 제거 알림 + if (statusPriority.length < originalPriorityCount) { + console.warn(`⚠️ STATUS_PRIORITY에서 유효하지 않은 값 ${originalPriorityCount - statusPriority.length}개 제거됨`); + } + + console.log(`📊 설정된 Status 우선순위: ${statusPriority.join(' > ')}`); + + } catch (error) { + console.error(`❌ STATUS_PRIORITY 파싱/검증 실패, 기본값 사용: ${error.message}`); + statusPriority = ['작업 완료','취소','피드백','확인 대기','작업 중','작업 전']; + console.log(`📊 기본 Status 우선순위: ${statusPriority.join(' > ')}`); + } + + // ===== 3. 현재 Label 중 Status Label 찾기 (모든 매칭 추적) ===== + const foundStatusLabels = []; + let targetStatus = ''; + + for (const status of statusPriority) { + if (labels.includes(status)) { + foundStatusLabels.push(status); + // 첫 번째로 발견된 것만 targetStatus로 설정 + if (!targetStatus) { + targetStatus = status; + } + } + } + + // ===== 4. 결과 로깅 (여러 Status Label이 있을 경우 명시적 알림) ===== + if (foundStatusLabels.length === 0) { + console.log('⚠️ Status Label이 없습니다. Status 업데이트 건너뜀'); + } else if (foundStatusLabels.length === 1) { + console.log(`✅ Status Label 발견: "${targetStatus}"`); + console.log(`🎯 Projects Status로 설정할 값: "${targetStatus}"`); + } else { + // 여러 Status Label이 동시에 존재하는 경우 + console.log(`⚠️ 여러 개의 Status Label이 발견되었습니다:`); + console.log(` 발견된 Labels: ${foundStatusLabels.join(', ')}`); + console.log(` 우선순위에 따라 선택된 Label: "${targetStatus}"`); + console.log(` 무시된 Labels: ${foundStatusLabels.slice(1).join(', ')}`); + console.log(`🎯 Projects Status로 설정할 값: "${targetStatus}"`); + console.log(''); + console.log('💡 권장사항: 하나의 Issue에는 하나의 Status Label만 사용하세요.'); + } + + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + return targetStatus; + + - name: Projects Status 업데이트 + if: steps.determine-status.outputs.result != '' + uses: actions/github-script@v7 + env: + TARGET_STATUS: ${{ fromJSON(steps.determine-status.outputs.result) }} + with: + github-token: ${{ secrets._GITHUB_PAT_TOKEN }} + script: | + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('🔄 Projects Status 업데이트 시작'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + const targetStatus = process.env.TARGET_STATUS; + const issueNodeId = context.payload.issue.node_id; + const projectUrl = '${{ env.PROJECT_URL }}'; + + console.log(`📌 Issue Node ID: ${issueNodeId}`); + console.log(`📌 목표 Status: "${targetStatus}"`); + console.log(`📌 프로젝트 URL: ${projectUrl}`); + + try { + // 1. 프로젝트 번호 추출 + const projectMatch = projectUrl.match(/\/projects\/(\d+)/); + if (!projectMatch) { + throw new Error('프로젝트 URL에서 번호를 추출할 수 없습니다.'); + } + const projectNumber = parseInt(projectMatch[1]); + console.log(`📊 프로젝트 번호: ${projectNumber}`); + + // 2. 조직 프로젝트 정보 조회 + const orgLogin = context.repo.owner; + console.log(`🏢 조직명: ${orgLogin}`); + + const projectQuery = ` + query($orgLogin: String!, $projectNumber: Int!) { + organization(login: $orgLogin) { + projectV2(number: $projectNumber) { + id + fields(first: 20) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { + id + name + } + } + } + } + } + } + } + `; + + const projectData = await github.graphql(projectQuery, { + orgLogin, + projectNumber + }); + + const project = projectData.organization.projectV2; + const projectId = project.id; + console.log(`✅ 프로젝트 ID: ${projectId}`); + + // 3. Status 필드 및 옵션 ID 찾기 + const statusField = project.fields.nodes.find( + field => field.name === '${{ env.STATUS_FIELD }}' + ); + + if (!statusField) { + throw new Error('Status 필드를 찾을 수 없습니다.'); + } + + const fieldId = statusField.id; + console.log(`✅ Status 필드 ID: ${fieldId}`); + + const statusOption = statusField.options.find( + option => option.name === targetStatus + ); + + if (!statusOption) { + throw new Error(`"${targetStatus}" 옵션을 찾을 수 없습니다.`); + } + + const optionId = statusOption.id; + console.log(`✅ "${targetStatus}" 옵션 ID: ${optionId}`); + + // 4. Issue의 프로젝트 아이템 ID 조회 + const itemQuery = ` + query($issueId: ID!) { + node(id: $issueId) { + ... on Issue { + projectItems(first: 10) { + nodes { + id + project { + id + } + } + } + } + } + } + `; + + const itemData = await github.graphql(itemQuery, { + issueId: issueNodeId + }); + + const projectItem = itemData.node.projectItems.nodes.find( + item => item.project.id === projectId + ); + + if (!projectItem) { + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('⚠️ 이 Issue가 프로젝트에 추가되지 않았습니다.'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(''); + console.log('📋 현재 상태:'); + console.log(` • Issue 번호: #${context.issue.number}`); + console.log(` • 프로젝트: ${projectUrl}`); + console.log(` • 프로젝트에 추가됨: ❌ 아니오`); + console.log(''); + console.log('💡 가능한 원인:'); + console.log(' 1. Issue가 생성된 직후 아직 프로젝트에 추가되지 않음'); + console.log(' 2. add-to-project Job이 실패했거나 실행되지 않음'); + console.log(' 3. Issue가 수동으로 프로젝트에서 제거됨'); + console.log(' 4. 프로젝트 URL이 잘못 설정됨'); + console.log(''); + console.log('🔧 해결 방법:'); + console.log(' 1. 프로젝트 페이지에서 수동으로 Issue를 추가하세요.'); + console.log(' 2. Issue Label을 다시 변경하여 워크플로우를 재실행하세요.'); + console.log(' 3. 워크플로우 로그에서 add-to-project Job 실행 여부를 확인하세요.'); + console.log(' 4. 환경변수 PROJECT_URL이 올바른지 확인하세요.'); + console.log(''); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + return; + } + + const itemId = projectItem.id; + console.log(`✅ 프로젝트 아이템 ID: ${itemId}`); + + // 5. Status 업데이트 뮤테이션 실행 + const updateMutation = ` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue( + input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + } + ) { + projectV2Item { + id + } + } + } + `; + + await github.graphql(updateMutation, { + projectId, + itemId, + fieldId, + optionId + }); + + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('🎉 Projects Status 업데이트 완료!'); + console.log(` • Issue: #${context.issue.number}`); + console.log(` • Status: "${targetStatus}"`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + } catch (error) { + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.error('❌ Status 업데이트 실패'); + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + // 에러 타입 분석 및 상세 안내 + const errorMessage = error.message || JSON.stringify(error); + const errorStatus = error.status || (error.response && error.response.status); + + if (errorStatus === 401 || errorMessage.includes('401') || errorMessage.includes('Unauthorized')) { + // 인증 오류 + console.error('🔐 인증 오류 (401 Unauthorized)'); + console.error(''); + console.error('원인:'); + console.error(' 1. _GITHUB_PAT_TOKEN Secret이 설정되지 않았습니다.'); + console.error(' 2. PAT 토큰이 만료되었습니다.'); + console.error(' 3. PAT 토큰에 필요한 권한(repo, project)이 없습니다.'); + console.error(''); + console.error('해결 방법:'); + console.error(' 1. GitHub Settings > Developer settings > Personal access tokens'); + console.error(' 2. Classic PAT 생성 (Fine-grained token은 GraphQL 미지원)'); + console.error(' 3. 권한 부여: repo (전체), project (read:project, write:project)'); + console.error(' 4. Organization Secrets에 _GITHUB_PAT_TOKEN으로 등록'); + + } else if (errorStatus === 404 || errorMessage.includes('404') || errorMessage.includes('Not Found')) { + // 리소스 찾을 수 없음 + console.error('🔍 리소스를 찾을 수 없습니다 (404 Not Found)'); + console.error(''); + console.error('원인:'); + console.error(' 1. 프로젝트 URL이 잘못되었습니다.'); + console.error(' 2. STATUS_FIELD 이름이 프로젝트 필드명과 일치하지 않습니다.'); + console.error(' 3. 설정하려는 Status 값이 프로젝트에 존재하지 않습니다.'); + console.error(' 4. PAT 토큰에 해당 프로젝트 접근 권한이 없습니다.'); + console.error(''); + console.error('해결 방법:'); + console.error(` 1. 프로젝트 URL 확인: ${projectUrl}`); + console.error(` 2. Status 필드명 확인: "${{ env.STATUS_FIELD }}"`); + console.error(` 3. Status 옵션 확인: "${targetStatus}"`); + console.error(' 4. PAT 토큰이 해당 Organization에 접근 가능한지 확인'); + + } else if (errorMessage.includes('rate limit') || errorMessage.includes('abuse') || errorMessage.includes('429')) { + // Rate Limiting + console.error('⏱️ API Rate Limit 초과 (429 Too Many Requests)'); + console.error(''); + console.error('원인:'); + console.error(' GitHub API 호출 횟수 제한을 초과했습니다.'); + console.error(' (시간당 5000 요청 제한)'); + console.error(''); + console.error('해결 방법:'); + console.error(' 1. 잠시 후 다시 시도하세요.'); + console.error(' 2. 워크플로우 트리거 빈도를 줄이세요.'); + console.error(' 3. GitHub API Rate Limit 상태를 확인하세요.'); + + } else if (errorMessage.includes('ETIMEDOUT') || errorMessage.includes('ECONNREFUSED') || errorMessage.includes('network')) { + // 네트워크 오류 + console.error('🌐 네트워크 오류'); + console.error(''); + console.error('원인:'); + console.error(' GitHub API 서버와 통신할 수 없습니다.'); + console.error(''); + console.error('해결 방법:'); + console.error(' 1. GitHub 서비스 상태를 확인하세요: https://www.githubstatus.com'); + console.error(' 2. 잠시 후 다시 시도하세요.'); + console.error(' 3. 워크플로우가 자동으로 재시도됩니다.'); + + } else if (errorMessage.includes('Cannot be updated') || errorMessage.includes('Field cannot be updated')) { + // 필드 업데이트 불가 + console.error('🚫 필드 업데이트 불가'); + console.error(''); + console.error('원인:'); + console.error(' 해당 필드는 GraphQL API로 업데이트할 수 없습니다.'); + console.error(' (Labels, Assignees, Milestone 등은 뮤테이션 미지원)'); + console.error(''); + console.error('해결 방법:'); + console.error(' 1. STATUS_FIELD가 Single Select 타입인지 확인하세요.'); + console.error(' 2. 프로젝트 설정에서 필드 타입을 확인하세요.'); + + } else { + // 기타 오류 + console.error('❓ 알 수 없는 오류'); + console.error(''); + console.error('상세 정보:'); + } + + console.error(''); + console.error('전체 에러 메시지:'); + console.error(errorMessage); + if (error.stack) { + console.error(''); + console.error('Stack Trace:'); + console.error(error.stack); + } + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + throw error; + } + + # =================================================================== + # Job 3: Issue/PR 닫기 시 "작업 완료" Status로 변경 + # =================================================================== + move-closed-to-done: + name: 닫힌 Issue/PR을 작업 완료로 이동 + if: | + github.event.action == 'closed' && + ( + github.event_name == 'issues' || + (github.event_name == 'pull_request' && false) + ) + runs-on: ubuntu-latest + steps: + - name: Projects Status를 "작업 완료"로 업데이트 + uses: actions/github-script@v7 + env: + DONE_STATUS: ${{ env.DONE_STATUS }} + with: + github-token: ${{ secrets._GITHUB_PAT_TOKEN }} + script: | + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('🏁 Issue/PR 닫기 감지 - 작업 완료 처리'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + const targetStatus = process.env.DONE_STATUS || '작업 완료'; + const itemNodeId = context.payload.issue?.node_id || context.payload.pull_request?.node_id; + const itemNumber = context.payload.issue?.number || context.payload.pull_request?.number; + const itemType = context.payload.issue ? 'Issue' : 'Pull Request'; + const projectUrl = '${{ env.PROJECT_URL }}'; + + console.log(`📌 ${itemType} #${itemNumber}`); + console.log(`📌 Node ID: ${itemNodeId}`); + console.log(`📌 목표 Status: "${targetStatus}"`); + + try { + // 1. 프로젝트 번호 추출 + const projectMatch = projectUrl.match(/\/projects\/(\d+)/); + if (!projectMatch) { + throw new Error('프로젝트 URL에서 번호를 추출할 수 없습니다.'); + } + const projectNumber = parseInt(projectMatch[1]); + + // 2. 조직 프로젝트 정보 조회 + const orgLogin = context.repo.owner; + + const projectQuery = ` + query($orgLogin: String!, $projectNumber: Int!) { + organization(login: $orgLogin) { + projectV2(number: $projectNumber) { + id + fields(first: 20) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { + id + name + } + } + } + } + } + } + } + `; + + const projectData = await github.graphql(projectQuery, { + orgLogin, + projectNumber + }); + + const project = projectData.organization.projectV2; + const projectId = project.id; + + // 3. Status 필드 및 "작업 완료" 옵션 ID 찾기 + const statusField = project.fields.nodes.find( + field => field.name === '${{ env.STATUS_FIELD }}' + ); + + if (!statusField) { + throw new Error('Status 필드를 찾을 수 없습니다.'); + } + + const fieldId = statusField.id; + + const doneOption = statusField.options.find( + option => option.name === targetStatus + ); + + if (!doneOption) { + throw new Error(`"${targetStatus}" 옵션을 찾을 수 없습니다.`); + } + + const optionId = doneOption.id; + console.log(`✅ "${targetStatus}" 옵션 ID: ${optionId}`); + + // 4. Issue/PR의 프로젝트 아이템 ID 조회 + const itemQuery = context.payload.issue + ? ` + query($itemId: ID!) { + node(id: $itemId) { + ... on Issue { + projectItems(first: 10) { + nodes { + id + project { + id + } + } + } + } + } + } + ` + : ` + query($itemId: ID!) { + node(id: $itemId) { + ... on PullRequest { + projectItems(first: 10) { + nodes { + id + project { + id + } + } + } + } + } + } + `; + + const itemData = await github.graphql(itemQuery, { + itemId: itemNodeId + }); + + const projectItem = itemData.node.projectItems.nodes.find( + item => item.project.id === projectId + ); + + if (!projectItem) { + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(`⚠️ 이 ${itemType}가 프로젝트에 추가되지 않았습니다.`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(''); + console.log('📋 현재 상태:'); + console.log(` • ${itemType} 번호: #${itemNumber}`); + console.log(` • 프로젝트: ${projectUrl}`); + console.log(` • 프로젝트에 추가됨: ❌ 아니오`); + console.log(''); + console.log('💡 가능한 원인:'); + console.log(' 1. 이 Issue/PR이 프로젝트에 추가되지 않았습니다.'); + console.log(' 2. add-to-project Job이 실패했거나 실행되지 않음'); + console.log(' 3. 수동으로 프로젝트에서 제거됨'); + console.log(' 4. 프로젝트 URL이 잘못 설정됨'); + console.log(''); + console.log('🔧 해결 방법:'); + console.log(' 1. 프로젝트 페이지에서 수동으로 추가 후 Status를 "작업 완료"로 변경하세요.'); + console.log(' 2. 워크플로우 로그에서 add-to-project Job 실행 여부를 확인하세요.'); + console.log(' 3. 환경변수 PROJECT_URL이 올바른지 확인하세요.'); + console.log(''); + console.log('ℹ️ 자동 완료 처리를 건너뜁니다.'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + return; + } + + const itemId = projectItem.id; + console.log(`✅ 프로젝트 아이템 ID: ${itemId}`); + + // 5. Status 업데이트 뮤테이션 실행 + const updateMutation = ` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue( + input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + } + ) { + projectV2Item { + id + } + } + } + `; + + await github.graphql(updateMutation, { + projectId, + itemId, + fieldId, + optionId + }); + + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('🎉 작업 완료 처리 성공!'); + console.log(` • ${itemType}: #${itemNumber}`); + console.log(` • Status: "${targetStatus}"`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + } catch (error) { + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.error('❌ 작업 완료 처리 실패'); + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + // 에러 타입 분석 및 상세 안내 + const errorMessage = error.message || JSON.stringify(error); + const errorStatus = error.status || (error.response && error.response.status); + + if (errorStatus === 401 || errorMessage.includes('401') || errorMessage.includes('Unauthorized')) { + // 인증 오류 + console.error('🔐 인증 오류 (401 Unauthorized)'); + console.error(''); + console.error('원인:'); + console.error(' 1. _GITHUB_PAT_TOKEN Secret이 설정되지 않았습니다.'); + console.error(' 2. PAT 토큰이 만료되었습니다.'); + console.error(' 3. PAT 토큰에 필요한 권한(repo, project)이 없습니다.'); + console.error(''); + console.error('해결 방법:'); + console.error(' 1. GitHub Settings > Developer settings > Personal access tokens'); + console.error(' 2. Classic PAT 생성 (Fine-grained token은 GraphQL 미지원)'); + console.error(' 3. 권한 부여: repo (전체), project (read:project, write:project)'); + console.error(' 4. Organization Secrets에 _GITHUB_PAT_TOKEN으로 등록'); + + } else if (errorStatus === 404 || errorMessage.includes('404') || errorMessage.includes('Not Found')) { + // 리소스 찾을 수 없음 + console.error('🔍 리소스를 찾을 수 없습니다 (404 Not Found)'); + console.error(''); + console.error('원인:'); + console.error(' 1. 프로젝트 URL이 잘못되었습니다.'); + console.error(' 2. STATUS_FIELD 이름이 프로젝트 필드명과 일치하지 않습니다.'); + console.error(' 3. DONE_STATUS 값이 프로젝트에 존재하지 않습니다.'); + console.error(' 4. PAT 토큰에 해당 프로젝트 접근 권한이 없습니다.'); + console.error(''); + console.error('해결 방법:'); + console.error(` 1. 프로젝트 URL 확인: ${projectUrl}`); + console.error(` 2. Status 필드명 확인: "${{ env.STATUS_FIELD }}"`); + console.error(` 3. DONE_STATUS 확인: "${targetStatus}"`); + console.error(' 4. PAT 토큰이 해당 Organization에 접근 가능한지 확인'); + + } else if (errorMessage.includes('rate limit') || errorMessage.includes('abuse') || errorMessage.includes('429')) { + // Rate Limiting + console.error('⏱️ API Rate Limit 초과 (429 Too Many Requests)'); + console.error(''); + console.error('원인:'); + console.error(' GitHub API 호출 횟수 제한을 초과했습니다.'); + console.error(' (시간당 5000 요청 제한)'); + console.error(''); + console.error('해결 방법:'); + console.error(' 1. 잠시 후 다시 시도하세요.'); + console.error(' 2. 워크플로우 트리거 빈도를 줄이세요.'); + console.error(' 3. GitHub API Rate Limit 상태를 확인하세요.'); + + } else if (errorMessage.includes('ETIMEDOUT') || errorMessage.includes('ECONNREFUSED') || errorMessage.includes('network')) { + // 네트워크 오류 + console.error('🌐 네트워크 오류'); + console.error(''); + console.error('원인:'); + console.error(' GitHub API 서버와 통신할 수 없습니다.'); + console.error(''); + console.error('해결 방법:'); + console.error(' 1. GitHub 서비스 상태를 확인하세요: https://www.githubstatus.com'); + console.error(' 2. 잠시 후 다시 시도하세요.'); + console.error(' 3. 워크플로우가 자동으로 재시도됩니다.'); + + } else if (errorMessage.includes('Cannot be updated') || errorMessage.includes('Field cannot be updated')) { + // 필드 업데이트 불가 + console.error('🚫 필드 업데이트 불가'); + console.error(''); + console.error('원인:'); + console.error(' 해당 필드는 GraphQL API로 업데이트할 수 없습니다.'); + console.error(' (Labels, Assignees, Milestone 등은 뮤테이션 미지원)'); + console.error(''); + console.error('해결 방법:'); + console.error(' 1. STATUS_FIELD가 Single Select 타입인지 확인하세요.'); + console.error(' 2. 프로젝트 설정에서 필드 타입을 확인하세요.'); + + } else { + // 기타 오류 + console.error('❓ 알 수 없는 오류'); + console.error(''); + console.error('상세 정보:'); + } + + console.error(''); + console.error('전체 에러 메시지:'); + console.error(errorMessage); + if (error.stack) { + console.error(''); + console.error('Stack Trace:'); + console.error(error.stack); + } + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + // 실패해도 워크플로우는 성공으로 처리 (GitHub 기본 자동화가 있을 수 있음) + console.log(''); + console.log('⚠️ GitHub Projects 기본 자동화가 활성화되어 있다면 자동으로 처리됩니다.'); + console.log(' Projects > Workflows 메뉴에서 "Item closed" 자동화를 확인하세요.'); + } diff --git a/.github/workflows/PROJECT-SPRING-SYNOLOGY-PR-PREVIEW.yaml b/.github/workflows/PROJECT-SPRING-SYNOLOGY-PR-PREVIEW.yaml index 872f2c3..c828996 100644 --- a/.github/workflows/PROJECT-SPRING-SYNOLOGY-PR-PREVIEW.yaml +++ b/.github/workflows/PROJECT-SPRING-SYNOLOGY-PR-PREVIEW.yaml @@ -107,7 +107,7 @@ env: # HEALTH_CHECK_PATH: '/actuator/health' # HEALTH_CHECK_LOG_PATTERN: 'Started .* in [0-9.]+ seconds' # API_DOCS_PATH: '/swagger-ui.html' 또는 '/swagger-ui/index.html' - HEALTH_CHECK_PATH: '/actuator/health' + HEALTH_CHECK_PATH: '/docs/swagger' HEALTH_CHECK_LOG_PATTERN: 'Started .* in [0-9.]+ seconds' API_DOCS_PATH: '/docs/swagger' diff --git a/CHANGELOG.json b/CHANGELOG.json index 79d1e39..85f8385 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -1,11 +1,35 @@ { "metadata": { - "lastUpdated": "2026-01-19T12:59:01Z", - "currentVersion": "0.1.16", + "lastUpdated": "2026-01-28T05:03:39Z", + "currentVersion": "0.1.24", "projectType": "spring", - "totalReleases": 5 + "totalReleases": 6 }, "releases": [ + { + "version": "0.1.24", + "project_type": "spring", + "date": "2026-01-28", + "pr_number": 18, + "raw_summary": "## Summary by CodeRabbit\n\n# 릴리스 노트\n\n* **새로운 기능**\n * 장소 정보 확대: 도로명 주소, 영업시간, 공휴일, 편의시설, TV 출연 기록, 메뉴 정보, 지하철 정보 등 추가 메타데이터 제공\n * SNS 콘텐츠 메타데이터 강화: 좋아요/댓글 수, 해시태그, 포스팅 시간, 저자 프로필 이미지 등 추가 정보 수집\n\n* **개선 사항**\n * 버전 업그레이드: v0.1.16 → v0.1.24\n * 헬스 체크 엔드포인트 개선", + "parsed_changes": { + "새로운_기능": { + "title": "새로운 기능", + "items": [ + "장소 정보 확대: 도로명 주소, 영업시간, 공휴일, 편의시설, TV 출연 기록, 메뉴 정보, 지하철 정보 등 추가 메타데이터 제공", + "SNS 콘텐츠 메타데이터 강화: 좋아요/댓글 수, 해시태그, 포스팅 시간, 저자 프로필 이미지 등 추가 정보 수집" + ] + }, + "개선_사항": { + "title": "개선 사항", + "items": [ + "버전 업그레이드: v0.1.16 → v0.1.24", + "헬스 체크 엔드포인트 개선" + ] + } + }, + "parse_method": "markdown" + }, { "version": "0.1.16", "project_type": "spring", diff --git a/CHANGELOG.md b/CHANGELOG.md index 8845541..d36bcc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,21 @@ # Changelog -**현재 버전:** 0.1.16 -**마지막 업데이트:** 2026-01-19T12:59:01Z +**현재 버전:** 0.1.24 +**마지막 업데이트:** 2026-01-28T05:03:39Z + +--- + +## [0.1.24] - 2026-01-28 + +**PR:** #18 + +**새로운 기능** +- 장소 정보 확대: 도로명 주소, 영업시간, 공휴일, 편의시설, TV 출연 기록, 메뉴 정보, 지하철 정보 등 추가 메타데이터 제공 +- SNS 콘텐츠 메타데이터 강화: 좋아요/댓글 수, 해시태그, 포스팅 시간, 저자 프로필 이미지 등 추가 정보 수집 + +**개선 사항** +- 버전 업그레이드: v0.1.16 → v0.1.24 +- 헬스 체크 엔드포인트 개선 --- diff --git a/Dockerfile b/Dockerfile index 20b21b9..163a407 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,9 +17,9 @@ RUN apk add --no-cache tzdata && \ # JAR 파일 복사 (MS-Web 모듈) COPY MS-Web/build/libs/app.jar app.jar -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 +# Health check (12시간마다 실행) +HEALTHCHECK --interval=43200s --timeout=10s --start-period=60s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/docs/swagger || exit 1 EXPOSE 8080 diff --git a/MS-AI/src/main/java/kr/suhsaechan/mapsy/ai/dto/AiCallbackRequest.java b/MS-AI/src/main/java/kr/suhsaechan/mapsy/ai/dto/AiCallbackRequest.java index 63b7f19..084926b 100644 --- a/MS-AI/src/main/java/kr/suhsaechan/mapsy/ai/dto/AiCallbackRequest.java +++ b/MS-AI/src/main/java/kr/suhsaechan/mapsy/ai/dto/AiCallbackRequest.java @@ -14,6 +14,8 @@ /** * AI 서버로부터 Webhook Callback으로 받는 분석 결과 DTO + * + * @see GitHub Issue #16 */ @Getter @Setter @@ -22,105 +24,200 @@ @AllArgsConstructor public class AiCallbackRequest { + @Schema(description = "Content UUID", example = "550e8400-e29b-41d4-a716-446655440000") + @NotNull(message = "contentId는 필수입니다.") + private UUID contentId; + @Schema(description = "처리 결과 상태", example = "SUCCESS", allowableValues = {"SUCCESS", "FAILED"}) @NotNull(message = "resultStatus는 필수입니다.") private String resultStatus; - @Schema(description = "Content UUID (FAILED 상태일 때 필수, SUCCESS일 때는 contentInfo.contentId 사용 가능)") - private UUID contentId; + @Schema(description = "SNS 콘텐츠 정보 (SUCCESS 시 필수)") + @Valid + private SnsInfoCallback snsInfo; - @Schema(description = "콘텐츠 정보") + @Schema(description = "추출된 장소 상세 목록") @Valid - private ContentInfo contentInfo; + private List placeDetails; - @Schema(description = "추출된 장소 목록") + @Schema(description = "추출 처리 통계") @Valid - private List places; + private ExtractionStatistics statistics; + + @Schema(description = "실패 사유 (FAILED 시)") + private String errorMessage; /** - * 콘텐츠 정보 + * SNS 콘텐츠 정보 */ @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder - public static class ContentInfo { - - @Schema(description = "Content UUID (백엔드에서 전송받은 UUID)", example = "123e4567-e89b-12d3-a456-426614174000") - @NotNull(message = "contentId는 필수입니다.") - private UUID contentId; + public static class SnsInfoCallback { - @Schema(description = "썸네일 URL", example = "https://img.youtube.com/vi/VIDEO_ID/maxresdefault.jpg") - @NotNull(message = "thumbnailUrl은 필수입니다.") - private String thumbnailUrl; - - @Schema(description = "SNS 플랫폼", example = "YOUTUBE", allowableValues = {"INSTAGRAM", "YOUTUBE", "YOUTUBE_SHORTS"}) + @Schema(description = "SNS 플랫폼", example = "INSTAGRAM", + allowableValues = {"INSTAGRAM", "YOUTUBE", "YOUTUBE_SHORTS", "TIKTOK", "FACEBOOK", "TWITTER"}) @NotNull(message = "platform은 필수입니다.") private String platform; - @Schema(description = "콘텐츠 제목", example = "일본 전국 라멘 투어 - 개당 1200원의 가성비 초밥") - // 필수 아님 - private String title; + @Schema(description = "콘텐츠 타입", example = "reel") + @NotNull(message = "contentType은 필수입니다.") + private String contentType; + + @Schema(description = "원본 SNS URL", example = "https://www.instagram.com/reel/ABC123/") + @NotNull(message = "url은 필수입니다.") + private String url; + + @Schema(description = "작성자 ID", example = "username") + private String author; + + @Schema(description = "게시물 본문", example = "여기 정말 맛있어! #맛집 #서울") + private String caption; + + @Schema(description = "좋아요 수", example = "1234") + private Integer likesCount; + + @Schema(description = "댓글 수", example = "56") + private Integer commentsCount; - @Schema(description = "콘텐츠 URL", example = "https://www.youtube.com/watch?v=VIDEO_ID") - private String contentUrl; + @Schema(description = "게시 날짜 (ISO 8601)", example = "2024-01-15T10:30:00Z") + private String postedAt; - @Schema(description = "업로더 아이디", example = "travel_lover_123") - private String platformUploader; + @Schema(description = "해시태그 리스트", example = "[\"맛집\", \"서울\"]") + private List hashtags; - @Schema(description = "AI 콘텐츠 요약", example = "샷포로 3대 스시 맛집 '토리톤' 방문...") - private String summary; + @Schema(description = "대표 이미지/썸네일 URL", example = "https://...") + private String thumbnailUrl; + + @Schema(description = "이미지 URL 리스트", example = "[\"https://...\"]") + private List imageUrls; + + @Schema(description = "작성자 프로필 이미지 URL", example = "https://...") + private String authorProfileImageUrl; } /** - * 장소 정보 + * 장소 상세 정보 */ @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder - public static class PlaceInfo { + public static class PlaceDetailCallback { - @Schema(description = "장소명", example = "명동 교자") + // 기본 정보 + @Schema(description = "네이버 Place ID", example = "11679241") + @NotNull(message = "placeId는 필수입니다.") + private String placeId; + + @Schema(description = "장소명", example = "늘푸른목장 잠실본점") @NotNull(message = "name은 필수입니다.") private String name; - @Schema(description = "주소", example = "서울특별시 중구 명동길 29") - @NotNull(message = "address는 필수입니다.") - private String address; + @Schema(description = "카테고리", example = "소고기구이") + private String category; - @Schema(description = "위도", example = "37.563476", requiredMode = Schema.RequiredMode.REQUIRED) - @NotNull(message = "latitude는 필수입니다.") + @Schema(description = "한줄 설명", example = "된장찌개와 냉면으로 완성하는 한상차림") + private String description; + + // 위치 정보 + @Schema(description = "위도", example = "37.5112") private Double latitude; - @Schema(description = "경도", example = "126.983920", requiredMode = Schema.RequiredMode.REQUIRED) - @NotNull(message = "longitude는 필수입니다.") + @Schema(description = "경도", example = "127.0867") private Double longitude; - @Schema(description = "국가 코드 (ISO 3166-1 alpha-2)", example = "KR") - private String country; + @Schema(description = "지번 주소", example = "서울 송파구 백제고분로9길 34 1F") + private String address; + + @Schema(description = "도로명 주소", example = "서울 송파구 백제고분로9길 34 1F") + private String roadAddress; - @Schema(description = "카테고리", example = "한식 음식점") - private String category; + @Schema(description = "지하철 정보", example = "잠실새내역 4번 출구에서 412m") + private String subwayInfo; - @Schema(description = "전화번호", example = "02-776-5348") - private String phone; + @Schema(description = "찾아가는 길", example = "잠실새내역 4번 출구에서 맥도널드 골목 끼고...") + private String directionsText; - @Schema(description = "영업시간", example = "매일 10:30 - 21:30") - private String openingHours; + // 평점/리뷰 + @Schema(description = "별점 (0.0~5.0)", example = "4.42") + private Double rating; - @Schema(description = "장소 설명", example = "칼국수와 만두로 유명한 맛집") - private String description; + @Schema(description = "방문자 리뷰 수", example = "1510") + private Integer visitorReviewCount; - @Schema(description = "AI 추출 원본 데이터", example = "명동 교자에서 칼국수 먹었어요 (caption, confidence: 0.95)") - private String rawData; + @Schema(description = "블로그 리뷰 수", example = "1173") + private Integer blogReviewCount; - @Schema(description = "언어 코드 (ISO 639-1)", example = "ko", allowableValues = {"ko", "en", "ja", "zh"}) - private String language = "ko"; + // 영업 정보 + @Schema(description = "영업 상태", example = "영업 중") + private String businessStatus; - @Schema(description = "키워드 목록", example = "[\"#명동맛집\", \"#칼국수\", \"#만두\"]") + @Schema(description = "영업 시간 요약", example = "24:00에 영업 종료") + private String businessHours; + + @Schema(description = "요일별 상세 영업시간", example = "[\"월 11:30 - 24:00\", \"화 11:30 - 24:00\"]") + private List openHoursDetail; + + @Schema(description = "휴무일 정보", example = "연중무휴") + private String holidayInfo; + + // 연락처/링크 + @Schema(description = "전화번호", example = "02-3431-4520") + private String phoneNumber; + + @Schema(description = "홈페이지 URL", example = "http://example.com") + private String homepageUrl; + + @Schema(description = "네이버 지도 URL", example = "https://map.naver.com/p/search/늘푸른목장/place/11679241") + private String naverMapUrl; + + @Schema(description = "예약 가능 여부", example = "true") + private Boolean reservationAvailable; + + // 부가 정보 + @Schema(description = "편의시설 목록", example = "[\"단체 이용 가능\", \"주차\", \"발렛파킹\"]") + private List amenities; + + @Schema(description = "키워드/태그", example = "[\"소고기\", \"한우\", \"회식\"]") private List keywords; + + @Schema(description = "TV 방송 출연 정보", example = "[\"줄서는식당 14회 (24.05.13)\"]") + private List tvAppearances; + + @Schema(description = "대표 메뉴", example = "[\"경주갈비살\", \"한우된장밥\"]") + private List menuInfo; + + @Schema(description = "대표 이미지 URL", example = "https://...") + private String imageUrl; + + @Schema(description = "이미지 URL 목록", example = "[\"https://...\"]") + private List imageUrls; + } + + /** + * 추출 처리 통계 + */ + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class ExtractionStatistics { + + @Schema(description = "LLM이 추출한 장소명 리스트", example = "[\"늘푸른목장\", \"강남역\"]") + private List extractedPlaceNames; + + @Schema(description = "LLM이 추출한 장소 수", example = "2") + private Integer totalExtracted; + + @Schema(description = "네이버 지도에서 찾은 장소 수", example = "1") + private Integer totalFound; + + @Schema(description = "검색 실패한 장소명", example = "[\"강남역\"]") + private List failedSearches; } } diff --git a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/service/NicknameService.java b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/service/NicknameService.java index f6ca660..2b1410a 100644 --- a/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/service/NicknameService.java +++ b/MS-Member/src/main/java/kr/suhsaechan/mapsy/member/service/NicknameService.java @@ -3,7 +3,7 @@ import kr.suhsaechan.mapsy.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import me.suhsaechan.suhrandomengine.core.SuhRandomKit; +import me.suhsaechan.suhnicknamegenerator.core.SuhRandomKit; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Service; diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/Place.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/Place.java index 7551922..b4fa90f 100644 --- a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/Place.java +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/entity/Place.java @@ -47,50 +47,104 @@ public class Place extends SoftDeletableBaseEntity { private String address; @Column(length = 2, nullable = false) - private String country; //국가 코드 (ISO 3166-1 alpha-2: KR, US, JP, CN 등) + @Builder.Default + private String country = "KR"; // 국가 코드 (ISO 3166-1 alpha-2) - 기본값 KR @Column(nullable = false, precision = 10, scale = 7) @DecimalMin("-90.0") @DecimalMax("90.0") - private BigDecimal latitude; //위도 + private BigDecimal latitude; // 위도 @Column(nullable = false, precision = 10, scale = 7) @DecimalMin("-180.0") @DecimalMax("180.0") - private BigDecimal longitude; //경도 + private BigDecimal longitude; // 경도 @Column(length = 100) - private String businessType; //업종 + private String businessType; // 업종 (category 매핑) @Column(length = 50) private String phone; @Column(length = 500) - private String openingHours; //영업시간 + private String openingHours; // 영업시간 (레거시, businessHours로 대체) @Column(columnDefinition = "TEXT") - private String description; //요약 설명 + private String description; // 요약 설명 - // Google Places API 추가 정보 + // Google Places API 추가 정보 (레거시) @Column(columnDefinition = "varchar(50)[]") @JdbcTypeCode(SqlTypes.ARRAY) - private List types; //장소 유형 배열 (restaurant, cafe, park 등) + private List types; // 장소 유형 배열 (restaurant, cafe, park 등) @Column(length = 30) - private String businessStatus; //영업 상태 (OPERATIONAL, CLOSED_TEMPORARILY, CLOSED_PERMANENTLY) + private String businessStatus; // 영업 상태 (영업 중, 영업 종료 등) @Column(length = 500) - private String iconUrl; //Google 아이콘 URL + private String iconUrl; // Google 아이콘 URL (레거시) - @Column(precision = 2, scale = 1) - private BigDecimal rating; //평점 (0.0 ~ 5.0) + @Column(precision = 3, scale = 2) + @DecimalMin("0.0") + @DecimalMax("5.0") + private BigDecimal rating; // 평점 (0.0 ~ 5.0) @Column - private Integer userRatingsTotal; //리뷰 수 + private Integer userRatingsTotal; // 리뷰 수 (레거시, visitorReviewCount로 대체) @Column(columnDefinition = "text[]") @JdbcTypeCode(SqlTypes.ARRAY) - private List photoUrls; //사진 URL 배열 (최대 10개) + private List photoUrls; // 사진 URL 배열 (최대 10개) + + // ========== 신규 필드 (AI 콜백 #16) ========== + + @Column(length = 500) + private String roadAddress; // 도로명 주소 + + @Column + private Integer visitorReviewCount; // 방문자 리뷰 수 + + @Column + private Integer blogReviewCount; // 블로그 리뷰 수 + + @Column(length = 500) + private String businessHours; // 영업 시간 요약 + + @Column(columnDefinition = "varchar(100)[]") + @JdbcTypeCode(SqlTypes.ARRAY) + private List openHoursDetail; // 요일별 상세 영업시간 + + @Column(length = 200) + private String holidayInfo; // 휴무일 정보 + + @Column(length = 500) + private String homepageUrl; // 홈페이지 URL + + @Column(length = 500) + private String naverMapUrl; // 네이버 지도 URL + + @Column + private Boolean reservationAvailable; // 예약 가능 여부 + + @Column(length = 200) + private String subwayInfo; // 지하철 정보 + + @Column(columnDefinition = "TEXT") + private String directionsText; // 찾아가는 길 + + @Column(columnDefinition = "varchar(100)[]") + @JdbcTypeCode(SqlTypes.ARRAY) + private List amenities; // 편의시설 목록 + + @Column(columnDefinition = "varchar(200)[]") + @JdbcTypeCode(SqlTypes.ARRAY) + private List tvAppearances; // TV 방송 출연 정보 + + @Column(columnDefinition = "varchar(200)[]") + @JdbcTypeCode(SqlTypes.ARRAY) + private List menuInfo; // 대표 메뉴 + + @Column(length = 500) + private String imageUrl; // 대표 이미지 URL /** * 이 장소와 연결된 키워드 목록 diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/PlaceKeywordRepository.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/PlaceKeywordRepository.java index be3ad95..9659e16 100644 --- a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/PlaceKeywordRepository.java +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/PlaceKeywordRepository.java @@ -53,14 +53,13 @@ public interface PlaceKeywordRepository extends JpaRepository */ @Query(""" - SELECT pk.place FROM PlaceKeyword pk - JOIN FETCH pk.place p + SELECT p FROM PlaceKeyword pk + JOIN pk.place p WHERE pk.keyword = :keyword AND p.isDeleted = false ORDER BY p.createdAt DESC @@ -74,8 +73,8 @@ public interface PlaceKeywordRepository extends JpaRepository */ @Query(""" - SELECT DISTINCT pk.place FROM PlaceKeyword pk - JOIN FETCH pk.place p + SELECT DISTINCT p FROM PlaceKeyword pk + JOIN pk.place p WHERE pk.keyword IN :keywords AND p.isDeleted = false ORDER BY p.createdAt DESC diff --git a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/PlacePlatformReferenceRepository.java b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/PlacePlatformReferenceRepository.java index f42e0f8..14b460f 100644 --- a/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/PlacePlatformReferenceRepository.java +++ b/MS-Place/src/main/java/kr/suhsaechan/mapsy/place/repository/PlacePlatformReferenceRepository.java @@ -32,4 +32,17 @@ public interface PlacePlatformReferenceRepository extends JpaRepository findByPlace(Place place); + + /** + * 플랫폼과 플랫폼 ID로 PlacePlatformReference 조회 + * - AI 콜백에서 네이버 placeId로 중복 체크 시 사용 + * + * @param placePlatform 플랫폼 (NAVER, GOOGLE, KAKAO) + * @param placePlatformId 플랫폼별 장소 ID + * @return Optional + */ + Optional findByPlacePlatformAndPlacePlatformId( + PlacePlatform placePlatform, + String placePlatformId + ); } diff --git a/MS-SNS/src/main/java/kr/suhsaechan/mapsy/sns/entity/Content.java b/MS-SNS/src/main/java/kr/suhsaechan/mapsy/sns/entity/Content.java index 2e18e52..7de4fa3 100644 --- a/MS-SNS/src/main/java/kr/suhsaechan/mapsy/sns/entity/Content.java +++ b/MS-SNS/src/main/java/kr/suhsaechan/mapsy/sns/entity/Content.java @@ -11,6 +11,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import java.time.LocalDateTime; +import java.util.List; import java.util.UUID; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -18,6 +19,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; @Entity @Builder @@ -44,7 +47,7 @@ public class Content extends SoftDeletableBaseEntity { @Column(length = 255) private String platformUploader; - @Column(length = 1000) + @Column(length = 2000) private String caption; @Column(length = 500) @@ -60,4 +63,29 @@ public class Content extends SoftDeletableBaseEntity { private String summary; private LocalDateTime lastCheckedAt; + + // ========== 신규 필드 (AI 콜백 #16) ========== + + @Column(length = 50) + private String contentType; + + @Column + private Integer likesCount; + + @Column + private Integer commentsCount; + + @Column + private LocalDateTime postedAt; + + @Column(columnDefinition = "varchar(100)[]") + @JdbcTypeCode(SqlTypes.ARRAY) + private List hashtags; + + @Column(columnDefinition = "text[]") + @JdbcTypeCode(SqlTypes.ARRAY) + private List imageUrls; + + @Column(length = 500) + private String authorProfileImageUrl; } diff --git a/MS-SNS/src/main/java/kr/suhsaechan/mapsy/sns/service/AiCallbackService.java b/MS-SNS/src/main/java/kr/suhsaechan/mapsy/sns/service/AiCallbackService.java index ff77a0e..ae7f7b2 100644 --- a/MS-SNS/src/main/java/kr/suhsaechan/mapsy/sns/service/AiCallbackService.java +++ b/MS-SNS/src/main/java/kr/suhsaechan/mapsy/sns/service/AiCallbackService.java @@ -1,6 +1,9 @@ package kr.suhsaechan.mapsy.sns.service; import kr.suhsaechan.mapsy.ai.dto.AiCallbackRequest; +import kr.suhsaechan.mapsy.ai.dto.AiCallbackRequest.PlaceDetailCallback; +import kr.suhsaechan.mapsy.ai.dto.AiCallbackRequest.SnsInfoCallback; +import kr.suhsaechan.mapsy.ai.dto.AiCallbackRequest.ExtractionStatistics; import kr.suhsaechan.mapsy.ai.dto.AiCallbackResponse; import kr.suhsaechan.mapsy.common.constant.ContentStatus; import kr.suhsaechan.mapsy.common.exception.CustomException; @@ -26,6 +29,9 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -34,7 +40,11 @@ import java.util.UUID; import kr.suhsaechan.mapsy.member.service.FcmService; -// AI 서버 Webhook Callback 처리 +/** + * AI 서버 Webhook Callback 처리 + * + * @see GitHub Issue #16 + */ @Service @RequiredArgsConstructor @Slf4j @@ -52,42 +62,31 @@ public class AiCallbackService { /** * AI 서버로부터 받은 Callback 처리 * - * - SUCCESS면 Place 저장 - * - FAILED면 상태만 변경 - * * @param request AI Callback 요청 * @return AI Callback 응답 */ @Transactional public AiCallbackResponse processAiServerCallback(AiCallbackRequest request) { - // contentId 추출: 최상위 contentId 우선, 없으면 ContentInfo에서 추출 - UUID contentId = request.getContentId() != null - ? request.getContentId() - : (request.getContentInfo() != null && request.getContentInfo().getContentId() != null - ? request.getContentInfo().getContentId() - : null); + UUID contentId = request.getContentId(); if (contentId == null) { - log.error("ContentInfo or contentId is null in callback request. resultStatus={}", request.getResultStatus()); + log.error("contentId is null in callback request. resultStatus={}", request.getResultStatus()); throw new CustomException(ErrorCode.INVALID_REQUEST); } log.info("Processing AI callback: contentId={}, resultStatus={}", contentId, request.getResultStatus()); - // Content 조회 - 없으면 예외 발생 + // Content 조회 Content content = contentRepository.findById(contentId) .orElseThrow(() -> new CustomException(ErrorCode.CONTENT_NOT_FOUND)); // 결과 상태에 따라 분기 처리 if ("SUCCESS".equals(request.getResultStatus())) { - // 성공 - Place 데이터 저장 - processAiServerSuccessCallback(content, request); + processSuccessCallback(content, request); } else if ("FAILED".equals(request.getResultStatus())) { - // 실패 - Content 상태만 FAILED로 변경 processFailedCallback(content, request); } else { - // 알 수 없는 상태값 - 에러 처리 log.error("Unknown resultStatus: {}", request.getResultStatus()); throw new CustomException(ErrorCode.INVALID_REQUEST); } @@ -102,120 +101,122 @@ public AiCallbackResponse processAiServerCallback(AiCallbackRequest request) { /** * 성공 Callback 처리 - * - * - Content가 COMPLETED 상태: 기존 ContentPlace 삭제 후 재생성 (업데이트 모드) - * - Content가 PENDING/FAILED 상태: 신규 ContentPlace 생성 - * - TODO: AI 서버에서 받은 Place 정보로 직접 Place 생성 (나중에 구현 예정) - * - * @param content 대상 Content - * @param request AI Callback 요청 */ - private void processAiServerSuccessCallback(Content content, AiCallbackRequest request) { + private void processSuccessCallback(Content content, AiCallbackRequest request) { log.debug("Processing SUCCESS callback for contentId={}", content.getId()); + // statistics 로깅 (DB 저장 안 함) + logStatistics(content.getId(), request.getStatistics()); + // Content가 이미 COMPLETED 상태인지 확인 (재처리 요청 판단용) boolean isContentAlreadyCompleted = (content.getStatus() == ContentStatus.COMPLETED); if (isContentAlreadyCompleted) { - // 업데이트 모드 - 기존 ContentPlace 모두 삭제 log.info("Content already COMPLETED. Updating existing data: contentId={}", content.getId()); contentPlaceRepository.deleteByContentIdWithFlush(content.getId()); log.debug("Deleted existing ContentPlaces for contentId={}", content.getId()); } - // Content 상태를 COMPLETED로 변경 (신규 또는 재처리 모두) + // Content 상태를 COMPLETED로 변경 content.setStatus(ContentStatus.COMPLETED); - // ContentInfo로 Content 메타데이터 업데이트 - updateContentWithContentInfo(content, request); + // SnsInfo로 Content 메타데이터 업데이트 + updateContentWithSnsInfo(content, request.getSnsInfo()); contentRepository.save(content); - // AI 서버에서 받은 Place 정보로 Place 생성 및 Content 연결 + // Place 생성 및 Content 연결 int placeCount = 0; - if (request.getPlaces() != null && !request.getPlaces().isEmpty()) { - List placeInfos = request.getPlaces(); + if (request.getPlaceDetails() != null && !request.getPlaceDetails().isEmpty()) { + List placeDetails = request.getPlaceDetails(); log.info("Received {} places for contentId={} (update mode: {}). Starting Place creation.", - placeInfos.size(), content.getId(), isContentAlreadyCompleted); + placeDetails.size(), content.getId(), isContentAlreadyCompleted); - // Place 생성 및 ContentPlace 연결 List savedPlaces = new ArrayList<>(); - for (AiCallbackRequest.PlaceInfo placeInfo : placeInfos) { + int position = 0; + for (PlaceDetailCallback placeDetail : placeDetails) { try { - // 좌표 검증 - if (placeInfo.getLatitude() == null || placeInfo.getLongitude() == null) { - log.error("Missing coordinates for place: {}. Skipping.", placeInfo.getName()); - continue; - } - // Place 생성 또는 조회 - Place place = createOrGetPlaceFromAiData(placeInfo); + Place place = createOrGetPlaceFromPlaceDetail(placeDetail); savedPlaces.add(place); - // ContentPlace 연결 (중복 체크) - createContentPlace(content, place); + // ContentPlace 연결 (순서 포함) + createContentPlace(content, place, position++); // 키워드 연결 - if (placeInfo.getKeywords() != null && !placeInfo.getKeywords().isEmpty()) { - keywordService.linkKeywordsToPlace(place, placeInfo.getKeywords()); - log.debug("Linked {} keywords to place: {}", placeInfo.getKeywords().size(), place.getName()); + if (placeDetail.getKeywords() != null && !placeDetail.getKeywords().isEmpty()) { + keywordService.linkKeywordsToPlace(place, placeDetail.getKeywords()); + log.debug("Linked {} keywords to place: {}", placeDetail.getKeywords().size(), place.getName()); } log.debug("Successfully processed place: {} (id={})", place.getName(), place.getId()); } catch (Exception e) { - log.error("Failed to process place: {}. Error: {}", placeInfo.getName(), e.getMessage(), e); - // 개별 장소 처리 실패 시 계속 진행 (다른 장소는 저장) + log.error("Failed to process place: {}. Error: {}", placeDetail.getName(), e.getMessage(), e); } } placeCount = savedPlaces.size(); log.info("Successfully saved {} out of {} places for contentId={}", - placeCount, placeInfos.size(), content.getId()); + placeCount, placeDetails.size(), content.getId()); } else { - // Place 데이터가 없는 경우 경고 로그 log.warn("No places found in callback for contentId={}", content.getId()); } - // AI 분석 완료 후 모든 요청 회원에게 알림 전송 - sendContentCompleteNotification(content, request, placeCount); + // 알림 전송 + sendContentCompleteNotification(content, placeCount); } /** - * ContentInfo로 Content 메타데이터 업데이트 - * - * null이 아닌 필드만 업데이트하여 기존 데이터 보존 - * - * @param content 대상 Content - * @param request AI Callback 요청 + * statistics 로깅 */ - private void updateContentWithContentInfo(Content content, AiCallbackRequest request) { - AiCallbackRequest.ContentInfo contentInfo = request.getContentInfo(); + private void logStatistics(UUID contentId, ExtractionStatistics statistics) { + if (statistics == null) { + return; + } + + log.info("Extraction statistics for contentId={}: totalExtracted={}, totalFound={}, failedSearches={}", + contentId, + statistics.getTotalExtracted(), + statistics.getTotalFound(), + statistics.getFailedSearches() != null ? statistics.getFailedSearches() : "[]"); - if (contentInfo == null) { - log.warn("ContentInfo is null for contentId={}. Skipping metadata update.", content.getId()); + if (statistics.getExtractedPlaceNames() != null) { + log.debug("Extracted place names for contentId={}: {}", contentId, statistics.getExtractedPlaceNames()); + } + } + + /** + * SnsInfo로 Content 메타데이터 업데이트 + */ + private void updateContentWithSnsInfo(Content content, SnsInfoCallback snsInfo) { + if (snsInfo == null) { + log.warn("SnsInfo is null for contentId={}. Skipping metadata update.", content.getId()); return; } - // title 업데이트 (null이 아닐 때만) - if (contentInfo.getTitle() != null) { - content.setTitle(contentInfo.getTitle()); + // platform 업데이트 + if (snsInfo.getPlatform() != null) { + try { + content.setPlatform(kr.suhsaechan.mapsy.sns.constant.ContentPlatform.valueOf(snsInfo.getPlatform())); + } catch (IllegalArgumentException e) { + log.error("Invalid platform value: {}. Keeping existing platform for contentId={}", + snsInfo.getPlatform(), content.getId()); + } } - // thumbnailUrl 업데이트 (null이 아닐 때만) - if (contentInfo.getThumbnailUrl() != null) { - content.setThumbnailUrl(contentInfo.getThumbnailUrl()); + // contentType 업데이트 + if (snsInfo.getContentType() != null) { + content.setContentType(snsInfo.getContentType()); } - // contentUrl 업데이트 (null이 아닐 때만) - originalUrl에 매핑 - if (contentInfo.getContentUrl() != null) { - String newUrl = contentInfo.getContentUrl(); - // 현재 URL과 같으면 업데이트 스킵 + // url → originalUrl 업데이트 + if (snsInfo.getUrl() != null) { + String newUrl = snsInfo.getUrl(); if (!newUrl.equals(content.getOriginalUrl())) { - // 다른 Content에 이미 존재하는지 확인 (unique constraint 위반 방지) Optional existingContent = contentRepository.findByOriginalUrl(newUrl); if (existingContent.isPresent() && !existingContent.get().getId().equals(content.getId())) { log.warn("Cannot update originalUrl: URL already exists in another Content. " + - "currentContentId={}, existingContentId={}, url={}", + "currentContentId={}, existingContentId={}, url={}", content.getId(), existingContent.get().getId(), newUrl); } else { content.setOriginalUrl(newUrl); @@ -223,199 +224,290 @@ private void updateContentWithContentInfo(Content content, AiCallbackRequest req } } - // platformUploader 업데이트 (null이 아닐 때만) - if (contentInfo.getPlatformUploader() != null) { - content.setPlatformUploader(contentInfo.getPlatformUploader()); + // author → platformUploader 업데이트 + if (snsInfo.getAuthor() != null) { + content.setPlatformUploader(snsInfo.getAuthor()); + } + + // caption 업데이트 + if (snsInfo.getCaption() != null) { + content.setCaption(snsInfo.getCaption()); } - // summary 업데이트 (null이 아닐 때만) - if (contentInfo.getSummary() != null) { - content.setSummary(contentInfo.getSummary()); + // likesCount 업데이트 + if (snsInfo.getLikesCount() != null) { + content.setLikesCount(snsInfo.getLikesCount()); } - // platform 업데이트 (null이 아닐 때만) - if (contentInfo.getPlatform() != null) { + // commentsCount 업데이트 + if (snsInfo.getCommentsCount() != null) { + content.setCommentsCount(snsInfo.getCommentsCount()); + } + + // postedAt 업데이트 (ISO 8601 파싱) + if (snsInfo.getPostedAt() != null) { try { - content.setPlatform(kr.suhsaechan.mapsy.sns.constant.ContentPlatform.valueOf(contentInfo.getPlatform())); - } catch (IllegalArgumentException e) { - log.error("Invalid platform value: {}. Keeping existing platform for contentId={}", - contentInfo.getPlatform(), content.getId()); + OffsetDateTime odt = OffsetDateTime.parse(snsInfo.getPostedAt()); + content.setPostedAt(odt.toLocalDateTime()); + } catch (DateTimeParseException e) { + log.warn("Failed to parse postedAt: {}. Skipping.", snsInfo.getPostedAt()); } } - log.debug("Updated Content with ContentInfo: contentId={}, title={}, summary={}, platformUploader={}", + // hashtags 업데이트 + if (snsInfo.getHashtags() != null) { + content.setHashtags(snsInfo.getHashtags()); + } + + // thumbnailUrl 업데이트 + if (snsInfo.getThumbnailUrl() != null) { + content.setThumbnailUrl(snsInfo.getThumbnailUrl()); + } + + // imageUrls 업데이트 + if (snsInfo.getImageUrls() != null) { + content.setImageUrls(snsInfo.getImageUrls()); + } + + // authorProfileImageUrl 업데이트 + if (snsInfo.getAuthorProfileImageUrl() != null) { + content.setAuthorProfileImageUrl(snsInfo.getAuthorProfileImageUrl()); + } + + log.debug("Updated Content with SnsInfo: contentId={}, platform={}, contentType={}, author={}", content.getId(), - contentInfo.getTitle() != null ? contentInfo.getTitle() : "(unchanged)", - contentInfo.getSummary() != null ? contentInfo.getSummary().substring(0, Math.min(30, contentInfo.getSummary().length())) + "..." : "(unchanged)", - contentInfo.getPlatformUploader() != null ? contentInfo.getPlatformUploader() : "(unchanged)"); + snsInfo.getPlatform(), + snsInfo.getContentType(), + snsInfo.getAuthor()); } /** * 실패 Callback 처리 - * - * Content 상태를 FAILED로 변경 - * - * @param content 대상 Content - * @param request AI Callback 요청 */ private void processFailedCallback(Content content, AiCallbackRequest request) { - log.error("Processing FAILED callback for contentId={}", content.getId()); + log.error("Processing FAILED callback for contentId={}, errorMessage={}", + content.getId(), request.getErrorMessage()); - // Content 상태를 FAILED로 변경하고 저장 content.setStatus(ContentStatus.FAILED); contentRepository.save(content); } /** - * AI 서버 응답 데이터로부터 Place 생성 또는 조회 - *

- * 위도/경도 기반 중복 체크 후 기존 Place 업데이트 또는 신규 생성 + * PlaceDetail로부터 Place 생성 또는 조회 * - * @param placeInfo AI 서버 응답 장소 정보 - * @return 생성 또는 업데이트된 Place + * 중복 체크 순서: + * 1. naverPlaceId로 PlacePlatformReference 검색 + * 2. name + 좌표로 기존 Place 검색 + * 3. 없으면 신규 생성 + PlacePlatformReference 생성 */ - private Place createOrGetPlaceFromAiData(AiCallbackRequest.PlaceInfo placeInfo) { - BigDecimal latitude = BigDecimal.valueOf(placeInfo.getLatitude()); - BigDecimal longitude = BigDecimal.valueOf(placeInfo.getLongitude()); - - // 이름+좌표로 중복 체크 - Optional existing = placeRepository.findByNameAndLatitudeAndLongitude( - placeInfo.getName(), - latitude, - longitude - ); - - if (existing.isPresent()) { - // 기존 Place 업데이트 - Place place = existing.get(); - updatePlaceFromAiData(place, placeInfo); - log.debug("Updated existing place: id={}, name={}, address={}", - place.getId(), place.getName(), place.getAddress()); - return placeRepository.save(place); - } else { - // 새로 생성 - Place newPlace = Place.builder() - .name(placeInfo.getName()) - .address(placeInfo.getAddress()) - .latitude(latitude) - .longitude(longitude) - .country(placeInfo.getCountry()) - .build(); - - // 선택 필드 설정 - if (placeInfo.getCategory() != null) { - newPlace.setTypes(placeInfo.getCategory()); - } - if (placeInfo.getPhone() != null) { - newPlace.setPhone(placeInfo.getPhone()); - } - if (placeInfo.getOpeningHours() != null) { - newPlace.setOpeningHours(placeInfo.getOpeningHours()); - } - if (placeInfo.getDescription() != null) { - newPlace.setDescription(placeInfo.getDescription()); + private Place createOrGetPlaceFromPlaceDetail(PlaceDetailCallback placeDetail) { + // 1. naverPlaceId로 중복 체크 + if (placeDetail.getPlaceId() != null) { + Optional existingRef = placePlatformReferenceRepository + .findByPlacePlatformAndPlacePlatformId(PlacePlatform.NAVER, placeDetail.getPlaceId()); + + if (existingRef.isPresent()) { + Place existingPlace = existingRef.get().getPlace(); + updatePlaceFromPlaceDetail(existingPlace, placeDetail); + log.debug("Found existing place by naverPlaceId: id={}, name={}", existingPlace.getId(), existingPlace.getName()); + return placeRepository.save(existingPlace); } + } + + // 2. name + 좌표로 중복 체크 (fallback) + if (placeDetail.getLatitude() != null && placeDetail.getLongitude() != null) { + BigDecimal latitude = BigDecimal.valueOf(placeDetail.getLatitude()); + BigDecimal longitude = BigDecimal.valueOf(placeDetail.getLongitude()); - Place savedPlace = placeRepository.save(newPlace); - log.debug("Created new place: id={}, name={}, lat={}, lng={}", - savedPlace.getId(), savedPlace.getName(), latitude, longitude); - return savedPlace; + Optional existing = placeRepository.findByNameAndLatitudeAndLongitude( + placeDetail.getName(), + latitude, + longitude + ); + + if (existing.isPresent()) { + Place existingPlace = existing.get(); + updatePlaceFromPlaceDetail(existingPlace, placeDetail); + + // PlacePlatformReference가 없으면 생성 + createPlacePlatformReferenceIfNotExists(existingPlace, placeDetail.getPlaceId()); + + log.debug("Found existing place by name+coords: id={}, name={}", existingPlace.getId(), existingPlace.getName()); + return placeRepository.save(existingPlace); + } } + + // 3. 신규 Place 생성 + Place newPlace = createNewPlace(placeDetail); + Place savedPlace = placeRepository.save(newPlace); + + // PlacePlatformReference 생성 + createPlacePlatformReferenceIfNotExists(savedPlace, placeDetail.getPlaceId()); + + log.debug("Created new place: id={}, name={}, naverPlaceId={}", + savedPlace.getId(), savedPlace.getName(), placeDetail.getPlaceId()); + return savedPlace; } /** - * AI 응답 데이터로 기존 Place 업데이트 - *

- * null이 아닌 필드만 업데이트하여 기존 데이터 보존 - * - * @param place 업데이트할 Place - * @param placeInfo AI 응답 장소 정보 + * 새 Place 엔티티 생성 */ - private void updatePlaceFromAiData(Place place, AiCallbackRequest.PlaceInfo placeInfo) { - if (placeInfo.getAddress() != null) { - place.setAddress(placeInfo.getAddress()); + private Place createNewPlace(PlaceDetailCallback placeDetail) { + BigDecimal latitude = placeDetail.getLatitude() != null + ? BigDecimal.valueOf(placeDetail.getLatitude()) + : BigDecimal.ZERO; + BigDecimal longitude = placeDetail.getLongitude() != null + ? BigDecimal.valueOf(placeDetail.getLongitude()) + : BigDecimal.ZERO; + + Place place = Place.builder() + .name(placeDetail.getName()) + .address(placeDetail.getAddress()) + .roadAddress(placeDetail.getRoadAddress()) + .latitude(latitude) + .longitude(longitude) + .country("KR") // 기본값 + .build(); + + // 모든 필드 설정 + updatePlaceFromPlaceDetail(place, placeDetail); + + return place; + } + + /** + * PlaceDetail로 기존 Place 업데이트 + */ + private void updatePlaceFromPlaceDetail(Place place, PlaceDetailCallback placeDetail) { + // 기본 정보 + if (placeDetail.getCategory() != null) { + place.setBusinessType(placeDetail.getCategory()); + } + if (placeDetail.getDescription() != null) { + place.setDescription(placeDetail.getDescription()); + } + + // 위치 정보 + if (placeDetail.getAddress() != null) { + place.setAddress(placeDetail.getAddress()); + } + if (placeDetail.getRoadAddress() != null) { + place.setRoadAddress(placeDetail.getRoadAddress()); + } + if (placeDetail.getSubwayInfo() != null) { + place.setSubwayInfo(placeDetail.getSubwayInfo()); + } + if (placeDetail.getDirectionsText() != null) { + place.setDirectionsText(placeDetail.getDirectionsText()); + } + + // 평점/리뷰 + if (placeDetail.getRating() != null) { + place.setRating(BigDecimal.valueOf(placeDetail.getRating())); + } + if (placeDetail.getVisitorReviewCount() != null) { + place.setVisitorReviewCount(placeDetail.getVisitorReviewCount()); + } + if (placeDetail.getBlogReviewCount() != null) { + place.setBlogReviewCount(placeDetail.getBlogReviewCount()); + } + + // 영업 정보 + if (placeDetail.getBusinessStatus() != null) { + place.setBusinessStatus(placeDetail.getBusinessStatus()); + } + if (placeDetail.getBusinessHours() != null) { + place.setBusinessHours(placeDetail.getBusinessHours()); + } + if (placeDetail.getOpenHoursDetail() != null) { + place.setOpenHoursDetail(placeDetail.getOpenHoursDetail()); + } + if (placeDetail.getHolidayInfo() != null) { + place.setHolidayInfo(placeDetail.getHolidayInfo()); + } + + // 연락처/링크 + if (placeDetail.getPhoneNumber() != null) { + place.setPhone(placeDetail.getPhoneNumber()); } - if (placeInfo.getCountry() != null) { - place.setCountry(placeInfo.getCountry()); + if (placeDetail.getHomepageUrl() != null) { + place.setHomepageUrl(placeDetail.getHomepageUrl()); } - if (placeInfo.getCategory() != null) { - place.setTypes(placeInfo.getCategory()); + if (placeDetail.getNaverMapUrl() != null) { + place.setNaverMapUrl(placeDetail.getNaverMapUrl()); } - if (placeInfo.getPhone() != null) { - place.setPhone(placeInfo.getPhone()); + if (placeDetail.getReservationAvailable() != null) { + place.setReservationAvailable(placeDetail.getReservationAvailable()); } - if (placeInfo.getOpeningHours() != null) { - place.setOpeningHours(placeInfo.getOpeningHours()); + + // 부가 정보 + if (placeDetail.getAmenities() != null) { + place.setAmenities(placeDetail.getAmenities()); + } + if (placeDetail.getTvAppearances() != null) { + place.setTvAppearances(placeDetail.getTvAppearances()); + } + if (placeDetail.getMenuInfo() != null) { + place.setMenuInfo(placeDetail.getMenuInfo()); + } + if (placeDetail.getImageUrl() != null) { + place.setImageUrl(placeDetail.getImageUrl()); } - if (placeInfo.getDescription() != null) { - place.setDescription(placeInfo.getDescription()); + if (placeDetail.getImageUrls() != null) { + place.setPhotoUrls(placeDetail.getImageUrls()); } } /** - * ContentPlace 연결 생성 (중복 체크 포함) - *

- * Content와 Place 매핑. 이미 존재하면 스킵 - * - * @param content 대상 Content - * @param place 대상 Place + * PlacePlatformReference 생성 (없으면) */ - private void createContentPlace(Content content, Place place) { - // 중복 체크 - boolean exists = contentPlaceRepository.existsByContentAndPlace(content, place); - if (exists) { - log.debug("ContentPlace already exists: contentId={}, placeId={}", content.getId(), place.getId()); + private void createPlacePlatformReferenceIfNotExists(Place place, String naverPlaceId) { + if (naverPlaceId == null) { return; } - // ContentPlace 엔티티 생성 - ContentPlace contentPlace = ContentPlace.builder() - .content(content) - .place(place) - .position(0) // AI 서버 응답에서는 순서 정보 없음 - .build(); + // 이미 존재하는지 확인 + Optional existing = placePlatformReferenceRepository + .findByPlaceAndPlacePlatform(place, PlacePlatform.NAVER); - // ContentPlace 저장 - contentPlaceRepository.save(contentPlace); - log.debug("Created ContentPlace: contentId={}, placeId={}", content.getId(), place.getId()); + if (existing.isEmpty()) { + PlacePlatformReference reference = PlacePlatformReference.builder() + .place(place) + .placePlatform(PlacePlatform.NAVER) + .placePlatformId(naverPlaceId) + .build(); + placePlatformReferenceRepository.save(reference); + log.debug("Created PlacePlatformReference: placeId={}, naverPlaceId={}", place.getId(), naverPlaceId); + } } /** * ContentPlace 연결 생성 (순서 포함) - *

- * Content와 Place 매핑 및 순서 저장 - * - * @param content 대상 Content - * @param place 대상 Place - * @param position 순서 */ private void createContentPlace(Content content, Place place, int position) { - // ContentPlace 엔티티 생성 + // 중복 체크 + boolean exists = contentPlaceRepository.existsByContentAndPlace(content, place); + if (exists) { + log.debug("ContentPlace already exists: contentId={}, placeId={}", content.getId(), place.getId()); + return; + } + ContentPlace contentPlace = ContentPlace.builder() .content(content) .place(place) - .position(position) // Place 순서 정보 + .position(position) .build(); - // ContentPlace 저장 contentPlaceRepository.save(contentPlace); log.debug("Created ContentPlace: contentId={}, placeId={}, position={}", content.getId(), place.getId(), position); } /** * Content 분석 완료 알림 전송 - *

- * 해당 Content를 요청한 모든 회원에게 FCM 알림 전송 - * notified=false인 ContentMember만 대상으로 함 - * - * @param content 완료된 Content - * @param request AI Callback 요청 - * @param placeCount 추출된 장소 개수 */ - private void sendContentCompleteNotification(Content content, AiCallbackRequest request, int placeCount) { + private void sendContentCompleteNotification(Content content, int placeCount) { log.info("Sending content complete notifications for contentId={}, placeCount={}", content.getId(), placeCount); - // 알림 미전송된 ContentMember 조회 (Member Fetch Join으로 N+1 방지) List unnotifiedMembers = contentMemberRepository.findUnnotifiedMembersWithMember(content.getId()); if (unnotifiedMembers.isEmpty()) { @@ -457,16 +549,14 @@ private void sendContentCompleteNotification(Content content, AiCallbackRequest List succeededMembers = new ArrayList<>(); for (ContentMember contentMember : unnotifiedMembers) { try { - // FCM 알림 전송 fcmService.sendNotificationToMember( contentMember.getMember().getId(), notificationTitle, notificationBody, notificationData, - content.getThumbnailUrl() // 썸네일 이미지 URL (null 가능) + content.getThumbnailUrl() ); - // 알림 전송 완료 표시 contentMember.setNotified(true); succeededMembers.add(contentMember); successCount++; @@ -476,11 +566,9 @@ private void sendContentCompleteNotification(Content content, AiCallbackRequest } catch (Exception e) { log.error("Failed to send notification to memberId={} for contentId={}: {}", contentMember.getMember().getId(), content.getId(), e.getMessage()); - // 실패해도 계속 진행 (다른 회원들에게는 알림 전송) } } - // 알림 전송 완료된 ContentMember만 업데이트 if (!succeededMembers.isEmpty()) { contentMemberRepository.saveAll(succeededMembers); } @@ -491,27 +579,18 @@ private void sendContentCompleteNotification(Content content, AiCallbackRequest /** * Content를 요청한 모든 회원에게 MemberPlace 생성 - * - TEMPORARY 상태로 초기화 - * - sourceContentId로 Content 추적 - * - 중복 생성 방지 (이미 존재하면 스킵) - * - * @param content 원본 Content - * @param place 생성된 Place */ private void createMemberPlaces(Content content, Place place) { - // 1. Content를 요청한 모든 회원 조회 (Member Fetch Join으로 N+1 방지) List contentMembers = contentMemberRepository.findAllByContentWithMember(content); log.info("Creating MemberPlace for {} members (contentId={}, placeId={})", contentMembers.size(), content.getId(), place.getId()); - // 2. 각 회원에 대해 MemberPlace 생성 int createdCount = 0; int skippedCount = 0; for (ContentMember contentMember : contentMembers) { try { - // 3. 이미 존재하는지 확인 (중복 방지) Optional existing = memberPlaceRepository .findByMemberAndPlaceAndDeletedAtIsNull(contentMember.getMember(), place); @@ -522,7 +601,6 @@ private void createMemberPlaces(Content content, Place place) { continue; } - // 4. MemberPlace 생성 및 저장 MemberPlace memberPlace = MemberPlace.builder() .member(contentMember.getMember()) .place(place) @@ -539,7 +617,6 @@ private void createMemberPlaces(Content content, Place place) { } catch (Exception e) { log.error("Failed to create MemberPlace for memberId={}, placeId={}: {}", contentMember.getMember().getId(), place.getId(), e.getMessage()); - // 실패해도 계속 진행 (다른 회원들의 MemberPlace는 생성) } } diff --git a/README.md b/README.md index eaf3a8c..0560e1a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ -## 최신 버전 : v0.1.13 (2026-01-19) +## 최신 버전 : v0.1.16 (2026-01-19) [전체 버전 기록 보기](CHANGELOG.md) diff --git a/build.gradle b/build.gradle index 74db046..179b8ee 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ bootJar { allprojects { group = 'kr.suhsaechan.mapsy' - version = '0.1.16' + version = '0.1.24' repositories { mavenCentral() diff --git a/version.yml b/version.yml index 969bea9..86154c0 100644 --- a/version.yml +++ b/version.yml @@ -33,11 +33,11 @@ # - project_type은 최초 설정 후 변경하지 마세요 # - 버전은 항상 높은 버전으로 자동 동기화됩니다 # =================================================================== -version: "0.1.16" -version_code: 20 # app build number +version: "0.1.24" +version_code: 28 # app build number project_type: "spring" # spring, flutter, next, react, react-native, react-native-expo, node, python, basic metadata: - last_updated: "2026-01-19 08:42:51" + last_updated: "2026-01-28 04:57:15" last_updated_by: "Cassiiopeia" default_branch: "main" integrated_from: "SUH-DEVOPS-TEMPLATE"