feat : github reverse sync manager 추가 #1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # =================================================================== | ||
| # GitHub Projects Status → Issue Label 역방향 동기화 워크플로우 | ||
| # =================================================================== | ||
| # | ||
| # 이 워크플로우는 GitHub Projects의 Status 변경을 Issue Label로 동기화합니다. | ||
| # | ||
| # 작동 방식: | ||
| # 1. Projects에서 Item의 Status가 변경되면 트리거 | ||
| # 2. GraphQL API로 변경된 Status 값 조회 | ||
| # 3. 해당 Issue의 기존 Status Label 제거 | ||
| # 4. 새로운 Status Label 추가 | ||
| # | ||
| # 지원 기능: | ||
| # - Projects Status 변경 → Issue Label 자동 동기화 | ||
| # • 작업 전 → 작업 전 | ||
| # • 작업 중 → 작업 중 | ||
| # • 확인 대기 → 확인 대기 | ||
| # • 피드백 → 피드백 | ||
| # • 작업 완료 → 작업 완료 | ||
| # • 취소 → 취소 | ||
| # - PR은 처리하지 않음 (Issue만 처리) | ||
| # - 무한 루프 방지 로직 포함 | ||
| # | ||
| # 필수 설정: | ||
| # - Organization Secret: _GITHUB_PAT_TOKEN | ||
| # • 필요 권한: repo (전체), project (read:project, write:project) | ||
| # • Classic PAT 필요 (Fine-grained token은 GraphQL API 미지원) | ||
| # | ||
| # 환경변수 설정: | ||
| # - PROJECT_NUMBER: GitHub Projects 번호 (필수) | ||
| # - STATUS_FIELD: Projects의 Status 필드명 (기본값: "Status") | ||
| # - STATUS_LABELS: 동기화할 Status Label 목록 (JSON 배열) | ||
| # | ||
| # 관련 워크플로우: | ||
| # - PROJECT-COMMON-PROJECTS-SYNC-MANAGER.yaml: Label → Status 동기화 (정방향) | ||
| # - 이 워크플로우: Status → Label 동기화 (역방향) | ||
| # | ||
| # =================================================================== | ||
| name: PROJECT-REVERSE-SYNC-MANAGER | ||
| on: | ||
| projects_v2_item: | ||
| types: [edited] | ||
| # =================================================================== | ||
| # 설정 변수 | ||
| # =================================================================== | ||
| env: | ||
| PROJECT_NUMBER: 6 | ||
| STATUS_FIELD: Status | ||
| STATUS_LABELS: '["작업 전","작업 중","확인 대기","피드백","작업 완료","취소"]' | ||
| permissions: | ||
| issues: write | ||
| contents: read | ||
| jobs: | ||
| # =================================================================== | ||
| # Job 1: Projects Status 변경 시 Issue Label 동기화 | ||
| # =================================================================== | ||
| sync-status-to-label: | ||
| name: Projects Status를 Issue Label로 동기화 | ||
| # 봇이 트리거한 경우 무한 루프 방지 | ||
| if: github.actor != 'github-actions[bot]' | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Project Item 정보 조회 및 Label 동기화 | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| PROJECT_NUMBER: ${{ env.PROJECT_NUMBER }} | ||
| STATUS_FIELD: ${{ env.STATUS_FIELD }} | ||
| STATUS_LABELS: ${{ env.STATUS_LABELS }} | ||
| with: | ||
| github-token: ${{ secrets._GITHUB_PAT_TOKEN }} | ||
| script: | | ||
| console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); | ||
| console.log('🔄 Projects Status → Issue Label 동기화 시작'); | ||
| console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); | ||
| const itemNodeId = context.payload.projects_v2_item?.node_id; | ||
| if (!itemNodeId) { | ||
| console.log('⚠️ Project item node_id를 찾을 수 없습니다.'); | ||
| console.log(' 페이로드:', JSON.stringify(context.payload, null, 2)); | ||
| return; | ||
| } | ||
| console.log(`📌 Project Item Node ID: ${itemNodeId}`); | ||
| try { | ||
| // ===== 1. Project Item 상세 정보 조회 ===== | ||
| const itemQuery = ` | ||
| query($itemId: ID!, $statusField: String!) { | ||
| node(id: $itemId) { | ||
| ... on ProjectV2Item { | ||
| content { | ||
| ... on Issue { | ||
| number | ||
| title | ||
| repository { | ||
| name | ||
| owner { | ||
| login | ||
| } | ||
| } | ||
| labels(first: 20) { | ||
| nodes { | ||
| name | ||
| } | ||
| } | ||
| } | ||
| ... on PullRequest { | ||
| number | ||
| title | ||
| } | ||
| } | ||
| fieldValueByName(name: $statusField) { | ||
| ... on ProjectV2ItemFieldSingleSelectValue { | ||
| name | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| `; | ||
| const itemData = await github.graphql(itemQuery, { | ||
| itemId: itemNodeId, | ||
| statusField: process.env.STATUS_FIELD | ||
| }); | ||
| console.log('📋 조회된 데이터:', JSON.stringify(itemData, null, 2)); | ||
| const content = itemData.node?.content; | ||
| if (!content) { | ||
| console.log('⚠️ Project item의 content를 찾을 수 없습니다.'); | ||
| return; | ||
| } | ||
| // PR인 경우 스킵 | ||
| if (!content.repository) { | ||
| console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); | ||
| console.log('ℹ️ Pull Request는 처리하지 않습니다.'); | ||
| console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); | ||
| return; | ||
| } | ||
| const issueNumber = content.number; | ||
| const repoName = content.repository.name; | ||
| const repoOwner = content.repository.owner.login; | ||
| const currentLabels = content.labels.nodes.map(l => l.name); | ||
| const newStatus = itemData.node?.fieldValueByName?.name; | ||
| console.log(`📌 Issue: ${repoOwner}/${repoName}#${issueNumber}`); | ||
| console.log(`📌 현재 Labels: ${currentLabels.join(', ') || '(없음)'}`); | ||
| console.log(`📌 새 Status: "${newStatus}"`); | ||
| if (!newStatus) { | ||
| console.log('⚠️ Status 값을 찾을 수 없습니다.'); | ||
| return; | ||
| } | ||
| // ===== 2. Status Labels 파싱 ===== | ||
| let statusLabels; | ||
| try { | ||
| statusLabels = JSON.parse(process.env.STATUS_LABELS); | ||
| } catch (e) { | ||
| console.error('❌ STATUS_LABELS 파싱 실패:', e.message); | ||
| statusLabels = ['작업 전', '작업 중', '확인 대기', '피드백', '작업 완료', '취소']; | ||
| } | ||
| console.log(`📊 관리 대상 Status Labels: ${statusLabels.join(', ')}`); | ||
| // ===== 3. 새 Status가 관리 대상인지 확인 ===== | ||
| if (!statusLabels.includes(newStatus)) { | ||
| console.log(`⚠️ "${newStatus}"는 관리 대상 Status Label이 아닙니다.`); | ||
| console.log(' Label 동기화를 건너뜁니다.'); | ||
| return; | ||
| } | ||
| // ===== 4. 이미 동일한 Label이 있는지 확인 (무한 루프 방지) ===== | ||
| if (currentLabels.includes(newStatus)) { | ||
| console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); | ||
| console.log(`✅ 이미 "${newStatus}" Label이 존재합니다.`); | ||
| console.log(' 동기화가 필요하지 않습니다.'); | ||
| console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); | ||
| return; | ||
| } | ||
| // ===== 5. 기존 Status Label 제거 ===== | ||
| const labelsToRemove = currentLabels.filter(label => | ||
| statusLabels.includes(label) && label !== newStatus | ||
| ); | ||
| console.log(`🗑️ 제거할 Labels: ${labelsToRemove.join(', ') || '(없음)'}`); | ||
| for (const label of labelsToRemove) { | ||
| try { | ||
| await github.rest.issues.removeLabel({ | ||
| owner: repoOwner, | ||
| repo: repoName, | ||
| issue_number: issueNumber, | ||
| name: label | ||
| }); | ||
| console.log(` ✅ "${label}" Label 제거됨`); | ||
| } catch (e) { | ||
| // Label이 이미 없는 경우 무시 | ||
| if (e.status !== 404) { | ||
| console.warn(` ⚠️ "${label}" Label 제거 실패:`, e.message); | ||
| } | ||
| } | ||
| } | ||
| // ===== 6. 새 Status Label 추가 ===== | ||
| console.log(`➕ 추가할 Label: "${newStatus}"`); | ||
| try { | ||
| await github.rest.issues.addLabels({ | ||
| owner: repoOwner, | ||
| repo: repoName, | ||
| issue_number: issueNumber, | ||
| labels: [newStatus] | ||
| }); | ||
| console.log(` ✅ "${newStatus}" Label 추가됨`); | ||
| } catch (e) { | ||
| console.error(` ❌ "${newStatus}" Label 추가 실패:`, e.message); | ||
| throw e; | ||
| } | ||
| console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); | ||
| console.log('🎉 Label 동기화 완료!'); | ||
| console.log(` • Issue: ${repoOwner}/${repoName}#${issueNumber}`); | ||
| console.log(` • Status: "${newStatus}"`); | ||
| console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); | ||
| } catch (error) { | ||
| console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); | ||
| console.error('❌ Label 동기화 실패'); | ||
| 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. Issue가 존재하지 않습니다.'); | ||
| console.error(' 2. Label이 리포지토리에 정의되어 있지 않습니다.'); | ||
| console.error(' 3. PAT 토큰에 해당 리포지토리 접근 권한이 없습니다.'); | ||
| console.error(''); | ||
| console.error('해결 방법:'); | ||
| console.error(' 1. Issue 존재 여부 확인'); | ||
| console.error(' 2. 리포지토리 Labels 설정에서 Status Label 존재 여부 확인'); | ||
| console.error(' 3. PAT 토큰 권한 확인'); | ||
| } else if (errorMessage.includes('rate limit') || errorMessage.includes('429')) { | ||
| console.error('⏱️ API Rate Limit 초과 (429 Too Many Requests)'); | ||
| console.error(''); | ||
| console.error('해결 방법:'); | ||
| console.error(' 잠시 후 다시 시도하세요.'); | ||
| } else { | ||
| 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; | ||
| } | ||