diff --git a/.claude/commands/init-workflow.md b/.claude/commands/init-workflow.md new file mode 100644 index 0000000..6ce2d84 --- /dev/null +++ b/.claude/commands/init-workflow.md @@ -0,0 +1,166 @@ +# init-workflow + +Git worktree를 자동으로 생성하는 커맨드입니다. + +브랜치명을 입력받아 자동으로: +1. 브랜치명에서 `#` 문자 제거 (Git 브랜치명으로 사용) +2. 브랜치가 없으면 생성 (현재 브랜치에서 분기) +3. 브랜치명의 특수문자를 `_`로 변환하여 폴더명 생성 +4. `{프로젝트명}-Worktree` 폴더에 worktree 생성 (예: `RomRom-FE-Worktree`) +5. 설정 파일 자동 복사 (Firebase, iOS, Android 키 등) +6. 이미 존재하면 경로만 출력 + +## 사용법 + +``` +/init-workflow + +20260120_#163_Github_Projects_에_대한_템플릿_개발_필요_및_관련_Sync_워크플로우_개발_필요 +``` + +## 실행 로직 + +1. 사용자 입력에서 두 번째 줄의 브랜치명 추출 +2. 브랜치명에서 `#` 문자 제거 +3. 임시 Python 스크립트 파일 생성 (인코딩 문제 해결) +4. Python 스크립트 실행 (worktree 생성 + 설정 파일 복사) +5. 임시 파일 자동 삭제 +6. 결과 출력 + +--- + +사용자 입력에서 두 번째 줄을 추출하여 브랜치명으로 사용하세요. + +브랜치명이 제공되지 않은 경우: +- 사용법을 안내하세요. + +브랜치명이 제공된 경우: +1. 프로젝트 루트로 이동 +2. Git 긴 경로 지원 활성화: `git config --global core.longpaths true` (최초 1회만 실행) +3. 브랜치명에서 `#` 문자 제거 (예: `20260116_#432_...` → `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` 분석 후 민감 파일 복사 + +**중요**: +- **브랜치명 처리**: `#` 문자는 Git 브랜치명에서 제거됩니다 (문제 방지) +- **인코딩 문제 해결**: 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..6ce2d84 --- /dev/null +++ b/.cursor/commands/init-workflow.md @@ -0,0 +1,166 @@ +# init-workflow + +Git worktree를 자동으로 생성하는 커맨드입니다. + +브랜치명을 입력받아 자동으로: +1. 브랜치명에서 `#` 문자 제거 (Git 브랜치명으로 사용) +2. 브랜치가 없으면 생성 (현재 브랜치에서 분기) +3. 브랜치명의 특수문자를 `_`로 변환하여 폴더명 생성 +4. `{프로젝트명}-Worktree` 폴더에 worktree 생성 (예: `RomRom-FE-Worktree`) +5. 설정 파일 자동 복사 (Firebase, iOS, Android 키 등) +6. 이미 존재하면 경로만 출력 + +## 사용법 + +``` +/init-workflow + +20260120_#163_Github_Projects_에_대한_템플릿_개발_필요_및_관련_Sync_워크플로우_개발_필요 +``` + +## 실행 로직 + +1. 사용자 입력에서 두 번째 줄의 브랜치명 추출 +2. 브랜치명에서 `#` 문자 제거 +3. 임시 Python 스크립트 파일 생성 (인코딩 문제 해결) +4. Python 스크립트 실행 (worktree 생성 + 설정 파일 복사) +5. 임시 파일 자동 삭제 +6. 결과 출력 + +--- + +사용자 입력에서 두 번째 줄을 추출하여 브랜치명으로 사용하세요. + +브랜치명이 제공되지 않은 경우: +- 사용법을 안내하세요. + +브랜치명이 제공된 경우: +1. 프로젝트 루트로 이동 +2. Git 긴 경로 지원 활성화: `git config --global core.longpaths true` (최초 1회만 실행) +3. 브랜치명에서 `#` 문자 제거 (예: `20260116_#432_...` → `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` 분석 후 민감 파일 복사 + +**중요**: +- **브랜치명 처리**: `#` 문자는 Git 브랜치명에서 제거됩니다 (문제 방지) +- **인코딩 문제 해결**: 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-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/CHANGELOG.json b/CHANGELOG.json index fe9fea4..1a8f3a7 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -1,11 +1,36 @@ { "metadata": { - "lastUpdated": "2026-01-18T15:27:46Z", - "currentVersion": "1.0.3", + "lastUpdated": "2026-01-27T07:12:32Z", + "currentVersion": "1.0.7", "projectType": "python", - "totalReleases": 8 + "totalReleases": 9 }, "releases": [ + { + "version": "1.0.7", + "project_type": "python", + "date": "2026-01-27", + "pr_number": 20, + "raw_summary": "## Summary by CodeRabbit\n\n## 릴리스 노트\n\n* **New Features**\n * Ollama LLM을 활용한 장소명 자동 추출 API 기능 추가\n * Git 워크트리 자동 생성 및 관리 도구 추가\n * GitHub 이슈/PR과 프로젝트 상태 자동 동기화 기능 추가\n\n* **Chores**\n * 버전 업데이트 (v1.0.7)\n * 설정 및 문서 업데이트", + "parsed_changes": { + "new_features": { + "title": "New Features", + "items": [ + "Ollama LLM을 활용한 장소명 자동 추출 API 기능 추가", + "Git 워크트리 자동 생성 및 관리 도구 추가", + "GitHub 이슈/PR과 프로젝트 상태 자동 동기화 기능 추가" + ] + }, + "chores": { + "title": "Chores", + "items": [ + "버전 업데이트 (v1.0.7)", + "설정 및 문서 업데이트" + ] + } + }, + "parse_method": "markdown" + }, { "version": "1.0.3", "project_type": "python", diff --git a/CHANGELOG.md b/CHANGELOG.md index 7280d59..a21166b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,22 @@ # Changelog -**현재 버전:** 1.0.3 -**마지막 업데이트:** 2026-01-18T15:27:46Z +**현재 버전:** 1.0.7 +**마지막 업데이트:** 2026-01-27T07:12:32Z + +--- + +## [1.0.7] - 2026-01-27 + +**PR:** #20 + +**New Features** +- Ollama LLM을 활용한 장소명 자동 추출 API 기능 추가 +- Git 워크트리 자동 생성 및 관리 도구 추가 +- GitHub 이슈/PR과 프로젝트 상태 자동 동기화 기능 추가 + +**Chores** +- 버전 업데이트 (v1.0.7) +- 설정 및 문서 업데이트 --- diff --git a/README.md b/README.md index 00b6503..7bf770d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # MapSy-AI -## 최신 버전 : v1.0.2 (2026-01-18) +## 최신 버전 : v1.0.4 (2026-01-18) [전체 버전 기록 보기](CHANGELOG.md) diff --git a/src/apis/test_router.py b/src/apis/test_router.py index e74a672..2d93fff 100644 --- a/src/apis/test_router.py +++ b/src/apis/test_router.py @@ -17,6 +17,7 @@ geocode_with_kakao, geocode_with_nominatim ) +from src.services.modules.ollama_llm import extract_place_names_with_ollama, OllamaPlaceResult from src.core.exceptions import CustomError logger = logging.getLogger(__name__) @@ -37,6 +38,11 @@ class GoogleMapSearchRequest(BaseModel): query: str = Field(..., description="검색어 (예: 늘푸른목장)", min_length=1) +class LlmPlaceExtractRequest(BaseModel): + """LLM 장소명 추출 요청""" + caption: str = Field(..., description="인스타그램 게시물 본문 텍스트", min_length=1) + + @router.post("/scrape", status_code=200) async def scrape_url(request: ScrapeRequest): """ @@ -141,4 +147,30 @@ async def test_geocode(request: GeocodingTestRequest): provider=result.provider ) except CustomError as error: - raise HTTPException(status_code=404, detail=error.message) \ No newline at end of file + raise HTTPException(status_code=404, detail=error.message) + + +@router.post("/llm-place-extract", response_model=OllamaPlaceResult, status_code=200) +async def extract_place_names(request: LlmPlaceExtractRequest): + """ + Ollama LLM을 사용하여 텍스트에서 장소명을 추출 + + 인스타그램 caption에서 장소명(가게명, 상호명, 식당명 등)을 추출합니다. + ai.suhsaechan.kr의 gemma3:1b-it-qat 모델을 사용합니다. + + - POST /api/test/llm-place-extract + - Body: {"caption": "1. #스시호 -위치_서울 송파구 가락로 98길..."} + - 성공: 200 + {"place_names": ["스시호", ...], "has_places": true} + - 실패 시에도 빈 결과 반환 (에러 발생하지 않음) + + 추출 규칙: + - 해시태그(#) 붙은 장소명도 추출 (#스시호 → 스시호) + - 일반 명사(맛집, 초밥 등)는 제외 + - 장소가 없으면 빈 배열 반환 + """ + logger.info(f"LLM 장소명 추출 요청: caption 길이={len(request.caption)}") + + result = await extract_place_names_with_ollama(request.caption) + + logger.info(f"장소명 추출 완료: {result.place_names}") + return result \ No newline at end of file diff --git a/src/core/config.py b/src/core/config.py index 69d39e8..2d4afe4 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -16,6 +16,11 @@ class Settings(BaseSettings): # 카카오 API KAKAO_REST_API_KEY: str + # Ollama API (ai.suhsaechan.kr) + OLLAMA_API_URL: str = "https://ai.suhsaechan.kr/api/chat" + OLLAMA_API_KEY: str = "" + OLLAMA_MODEL: str = "gemma3:1b-it-qat" + # SMB 설정 SMB_HOST: str = "" SMB_PORT: int = 445 diff --git a/src/services/modules/ollama_llm.py b/src/services/modules/ollama_llm.py new file mode 100644 index 0000000..841574b --- /dev/null +++ b/src/services/modules/ollama_llm.py @@ -0,0 +1,158 @@ +"""src.services.modules.ollama_llm +Ollama API를 사용하여 텍스트에서 장소명을 추출합니다. +ai.suhsaechan.kr 서버의 gemma3:1b-it-qat 모델을 사용합니다. +""" + +import json +import logging +from pydantic import BaseModel, Field + +from src.core.config import settings +from src.core.exceptions import CustomError +from src.utils.common import http_post_json + +logger = logging.getLogger(__name__) + + +# ============================================= +# Pydantic 모델 +# ============================================= +class OllamaPlaceResult(BaseModel): + """Ollama 장소명 추출 결과""" + place_names: list[str] = Field(default_factory=list, description="추출된 장소명 리스트") + has_places: bool = Field(default=False, description="장소 존재 여부") + + +# ============================================= +# JSON Schema (Ollama format 파라미터용) +# ============================================= +PLACE_EXTRACTION_SCHEMA = { + "type": "object", + "properties": { + "place_names": { + "type": "array", + "items": {"type": "string"} + }, + "has_places": { + "type": "boolean" + } + }, + "required": ["place_names", "has_places"], + "additionalProperties": False +} + + +# ============================================= +# 프롬프트 템플릿 +# ============================================= +PLACE_EXTRACTION_PROMPT = """다음 텍스트에서 장소명(가게명, 상호명, 식당명, 카페명, 관광지명 등)을 추출하세요. + +장소명 예시: +- 스타벅스 종합운동장사거리점 +- 블루보틀 성수 +- 스시호 +- 사사노하 + +규칙: +1. 텍스트에 언급된 실제 장소명만 추출하세요. +2. 해시태그(#)가 붙어있어도 장소명이면 추출하세요. (#스시호 → 스시호) +3. 일반 명사(맛집, 초밥, 카페 등)는 장소명이 아닙니다. +4. 장소가 없으면 빈 배열 []을 반환하세요. + + +{caption} +""" + + +# ============================================= +# Ollama API 호출 함수 +# ============================================= +async def extract_place_names_with_ollama( + caption: str, + max_retries: int = 3 +) -> OllamaPlaceResult: + """ + Ollama API를 사용하여 텍스트에서 장소명을 추출합니다. + + Args: + caption: 인스타그램 게시물 본문 텍스트 + max_retries: 최대 재시도 횟수 (기본 3회) + + Returns: + OllamaPlaceResult: 추출된 장소명 리스트와 존재 여부 + + Raises: + CustomError: API 호출 실패 또는 파싱 실패 시 + """ + if not caption or not caption.strip(): + logger.warning("빈 caption이 전달되었습니다.") + return OllamaPlaceResult(place_names=[], has_places=False) + + prompt = PLACE_EXTRACTION_PROMPT.format(caption=caption) + + request_body = { + "model": settings.OLLAMA_MODEL, + "messages": [ + { + "role": "user", + "content": prompt + } + ], + "stream": False, + "format": PLACE_EXTRACTION_SCHEMA + } + + headers = { + "Content-Type": "application/json", + "X-API-KEY": settings.OLLAMA_API_KEY + } + + last_error = None + + for attempt in range(1, max_retries + 1): + try: + logger.info(f"Ollama API 호출 시도 {attempt}/{max_retries} (model={settings.OLLAMA_MODEL})") + + response = await http_post_json( + url=settings.OLLAMA_API_URL, + json_body=request_body, + headers=headers + ) + + # 응답에서 content 추출 + message = response.get("message", {}) + content = message.get("content", "") + + if not content: + logger.warning(f"Ollama 응답에 content가 없습니다: {response}") + last_error = CustomError("Ollama 응답에 content가 없습니다") + continue + + # JSON 파싱 + try: + parsed = json.loads(content) + result = OllamaPlaceResult.model_validate(parsed) + + logger.info(f"장소명 추출 성공: {result.place_names}") + return result + + except json.JSONDecodeError as json_error: + logger.warning(f"JSON 파싱 실패 (시도 {attempt}): {json_error}") + last_error = CustomError(f"JSON 파싱 실패: {json_error}") + continue + + except CustomError as error: + logger.warning(f"Ollama API 호출 실패 (시도 {attempt}): {error.message}") + last_error = error + continue + + except Exception as error: + logger.error(f"예기치 않은 오류 (시도 {attempt}): {error}") + last_error = CustomError(f"예기치 않은 오류: {error}") + continue + + # 모든 재시도 실패 + logger.error(f"Ollama API 호출 {max_retries}회 모두 실패") + + # 실패 시 빈 결과 반환 (에러를 던지지 않음) + return OllamaPlaceResult(place_names=[], has_places=False) diff --git a/src/utils/common.py b/src/utils/common.py index 27a0e67..b997542 100644 --- a/src/utils/common.py +++ b/src/utils/common.py @@ -56,6 +56,7 @@ def validate_url_length(url: str, max_length: int = 2048) -> None: # ============================================================ DEFAULT_HTTP_TIMEOUT = 10.0 # 기본 타임아웃 (초) +OLLAMA_HTTP_TIMEOUT = 120.0 # Ollama API 타임아웃 (2분, 긴 텍스트 처리) async def http_get_json( @@ -98,6 +99,46 @@ async def http_get_json( raise CustomError("API 연결에 실패했습니다") +async def http_post_json( + url: str, + json_body: dict[str, Any], + headers: dict[str, str] | None = None, + timeout: float = OLLAMA_HTTP_TIMEOUT +) -> dict[str, Any]: + """ + HTTP POST 요청 후 JSON 응답 반환 + + Args: + url: 요청 URL + json_body: 요청 바디 (JSON) + headers: 요청 헤더 + timeout: 타임아웃 (초, 기본 120초 - Ollama용) + + Returns: + dict: JSON 응답 + + Raises: + CustomError: 요청 실패 또는 응답 오류 시 + """ + try: + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post(url, json=json_body, headers=headers) + response.raise_for_status() + return response.json() + + except httpx.TimeoutException: + logger.error(f"HTTP POST 요청 타임아웃: url={url}") + raise CustomError(f"요청 시간이 초과되었습니다 ({timeout}초)") + + except httpx.HTTPStatusError as error: + logger.error(f"HTTP POST 응답 오류: status={error.response.status_code}, url={url}") + raise CustomError(f"API 오류: {error.response.status_code}") + + except httpx.RequestError as error: + logger.error(f"HTTP POST 연결 실패: url={url}, error={error}") + raise CustomError("API 연결에 실패했습니다") + + def mask_sensitive_data(data: str, show_chars: int = 2) -> str: """ 민감 데이터 마스킹 (로그 출력 시 사용) diff --git a/version.yml b/version.yml index 85e0133..df64007 100644 --- a/version.yml +++ b/version.yml @@ -34,11 +34,11 @@ # - 버전은 항상 높은 버전으로 자동 동기화됩니다 # =================================================================== -version: "1.0.4" -version_code: 20 # app build number +version: "1.0.7" +version_code: 23 # app build number project_type: "python" # spring, flutter, react, react-native, react-native-expo, node, python, basic metadata: - last_updated: "2026-01-18 15:27:46" + last_updated: "2026-01-27 07:10:52" last_updated_by: "Cassiiopeia" default_branch: "main" integrated_from: "SUH-DEVOPS-TEMPLATE"