Skip to content

feat : github reverse sync manager 추가 #1

feat : github reverse sync manager 추가

feat : github reverse sync manager 추가 #1

# ===================================================================

Check failure on line 1 in .github/workflows/PROJECT-REVERSE-SYNC-MANAGER.yaml

View workflow run for this annotation

GitHub Actions / .github/workflows/PROJECT-REVERSE-SYNC-MANAGER.yaml

Invalid workflow file

(Line: 43, Col: 3): Unexpected value 'projects_v2_item'
# 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;
}