diff --git a/.github/workflows/github-jira-issue-sync.yml b/.github/workflows/github-jira-issue-sync.yml new file mode 100644 index 0000000..bfb9501 --- /dev/null +++ b/.github/workflows/github-jira-issue-sync.yml @@ -0,0 +1,334 @@ +name: Issue-Jira Sync + +on: + issues: + types: [opened, reopened] + + workflow_dispatch: + inputs: + process_all_open_issues: + description: '수동 트리거로 기존 이슈 일괄 처리' + type: boolean + default: true + +permissions: + issues: write + contents: read + +jobs: + sync-issue-to-jira: + if: github.event_name == 'issues' + runs-on: ubuntu-latest + steps: + - name: Check Existing Jira Link + id: check-jira + uses: actions/github-script@v7 + with: + script: | + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number + }); + const hasJira = comments.data.some(c => c.body.includes('Jira:')); + return hasJira; + + - name: Login to Jira + if: steps.check-jira.outputs.result == 'false' + uses: atlassian/gajira-login@v3 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + + - name: Parse Issue Template + if: steps.check-jira.outputs.result == 'false' + id: parse + uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + const labels = issue.labels.map(l => l.name); + const title = issue.title; + const body = issue.body || ''; + + // 라벨 기반 Jira Type 결정 + let jiraType = 'Task'; + if (labels.includes('epic')) jiraType = 'Epic'; + else if (labels.includes('story')) jiraType = 'Story'; + else if (labels.includes('bug')) jiraType = 'Bug'; + // task, change-request, spike → Task + + // 제목 prefix로 백업 판단 + if (jiraType === 'Task') { + if (/^\[EPIC\]/i.test(title)) jiraType = 'Epic'; + else if (/^\[STORY\]/i.test(title)) jiraType = 'Story'; + else if (/^\[BUG\]/i.test(title)) jiraType = 'Bug'; + } + + // 템플릿 필드 파싱 (### 헤더 기반) + const parseSection = (label) => { + const regex = new RegExp(`### ${label}\\s*\\n([\\s\\S]*?)(?=###|$)`); + const match = body.match(regex); + return match ? match[1].trim() : ''; + }; + + // 공통 + 템플릿별 필드 + const fields = { + // Epic + goal: parseSection('목표'), + scope: parseSection('범위 / Not-in-scope') || parseSection('작업 범위'), + breakdown: parseSection('하위 스토리\\(체크리스트\\)'), + milestone: parseSection('마일스톤'), + // Story + background: parseSection('배경'), + ac: parseSection('수용 기준\\(AC\\)'), + design: parseSection('디자인/문서 링크') || parseSection('디자인/계약 링크'), + notes: parseSection('구현 메모/리스크'), + epic: parseSection('연결된 Epic'), + // Task + parent: parseSection('연결된 Story/Epic'), + done: parseSection('Done 기준'), + // Change Request + related: parseSection('영향받는 Epic/Story/Task'), + change: parseSection('제안 변경 사항'), + impact: parseSection('영향도'), + decision: parseSection('결정/대안/근거\\(ADR 링크\\)'), + // Spike + timebox: parseSection('타임박스'), + questions: parseSection('핵심 질문'), + approach: parseSection('접근 방법'), + deliverables: parseSection('산출물\\(요약/ADR/POC 링크\\)') + }; + + // 템플릿 타입 판단 + let templateType = 'task'; + if (labels.includes('epic') || /^\[EPIC\]/i.test(title)) templateType = 'epic'; + else if (labels.includes('story') || /^\[STORY\]/i.test(title)) templateType = 'story'; + else if (labels.includes('change-request') || /^\[CR\]/i.test(title)) templateType = 'cr'; + else if (labels.includes('spike') || /^\[SPIKE\]/i.test(title)) templateType = 'spike'; + + core.setOutput('jira_type', jiraType); + core.setOutput('template_type', templateType); + core.setOutput('goal', fields.goal); + core.setOutput('scope', fields.scope); + core.setOutput('breakdown', fields.breakdown); + core.setOutput('background', fields.background); + core.setOutput('ac', fields.ac); + core.setOutput('design', fields.design); + core.setOutput('parent', fields.parent); + core.setOutput('change', fields.change); + core.setOutput('impact', fields.impact); + core.setOutput('timebox', fields.timebox); + core.setOutput('questions', fields.questions); + + - name: Build Jira Description + if: steps.check-jira.outputs.result == 'false' + id: description + uses: actions/github-script@v7 + with: + script: | + const templateType = '${{ steps.parse.outputs.template_type }}'; + + let desc = `h3. GitHub Issue\n${{ github.event.issue.html_url }}\n\nh3. Author\n${{ github.event.issue.user.login }}\n\n`; + + switch (templateType) { + case 'epic': + desc += `h3. 목표\n${{ steps.parse.outputs.goal }}\n\n`; + desc += `h3. 범위\n${{ steps.parse.outputs.scope }}\n\n`; + desc += `h3. 하위 스토리\n${{ steps.parse.outputs.breakdown }}\n`; + break; + case 'story': + desc += `h3. 배경\n${{ steps.parse.outputs.background }}\n\n`; + desc += `h3. 수용 기준(AC)\n${{ steps.parse.outputs.ac }}\n\n`; + desc += `h3. 디자인\n${{ steps.parse.outputs.design }}\n`; + break; + case 'cr': + desc += `h3. 제안 변경 사항\n${{ steps.parse.outputs.change }}\n\n`; + desc += `h3. 영향도\n${{ steps.parse.outputs.impact }}\n`; + break; + case 'spike': + desc += `h3. 타임박스\n${{ steps.parse.outputs.timebox }}\n\n`; + desc += `h3. 핵심 질문\n${{ steps.parse.outputs.questions }}\n`; + break; + default: // task + desc += `h3. 연결된 Story/Epic\n${{ steps.parse.outputs.parent }}\n\n`; + desc += `h3. 작업 범위\n${{ steps.parse.outputs.scope }}\n`; + } + + core.setOutput('content', desc); + + - name: Create Jira Issue + if: steps.check-jira.outputs.result == 'false' + id: create-jira + uses: atlassian/gajira-create@v3 + with: + project: MESP + issuetype: ${{ steps.parse.outputs.jira_type }} + summary: '[Issue-#${{ github.event.issue.number }}] ${{ github.event.issue.title }}' + description: '${{ steps.description.outputs.content }}' + + - name: Add Jira Link Comment + if: steps.check-jira.outputs.result == 'false' + uses: actions/github-script@v7 + with: + script: | + const jiraKey = '${{ steps.create-jira.outputs.issue }}'; + const jiraUrl = '${{ secrets.JIRA_BASE_URL }}/browse/' + jiraKey; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + body: `Jira: [${jiraKey}](${jiraUrl})` + }); + + # 기존 이슈 일괄 처리 + sync-all-open-issues: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - name: Process All Open Issues + uses: actions/github-script@v7 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + with: + script: | + const issues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100 + }); + + const realIssues = issues.data.filter(issue => !issue.pull_request); + console.log(`Found ${realIssues.length} open issues`); + + for (const issue of realIssues) { + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number + }); + + const hasJiraLink = comments.data.some(c => c.body.includes('Jira:')); + if (hasJiraLink) { + console.log(`Issue #${issue.number} already has Jira link, skipping`); + continue; + } + + console.log(`Processing Issue #${issue.number}: ${issue.title}`); + + const labels = issue.labels.map(l => l.name); + const title = issue.title; + const body = issue.body || ''; + + // Jira Type 결정 + let jiraType = 'Task'; + if (labels.includes('epic') || /^\[EPIC\]/i.test(title)) jiraType = 'Epic'; + else if (labels.includes('story') || /^\[STORY\]/i.test(title)) jiraType = 'Story'; + else if (labels.includes('bug') || /^\[BUG\]/i.test(title)) jiraType = 'Bug'; + + // 템플릿 타입 판단 + let templateType = 'task'; + if (labels.includes('epic') || /^\[EPIC\]/i.test(title)) templateType = 'epic'; + else if (labels.includes('story') || /^\[STORY\]/i.test(title)) templateType = 'story'; + else if (labels.includes('change-request') || /^\[CR\]/i.test(title)) templateType = 'cr'; + else if (labels.includes('spike') || /^\[SPIKE\]/i.test(title)) templateType = 'spike'; + + // 섹션 파싱 + const parseSection = (label) => { + const regex = new RegExp(`### ${label}\\s*\\n([\\s\\S]*?)(?=###|$)`); + const match = body.match(regex); + return match ? match[1].trim() : '-'; + }; + + // description 구성 + const descContent = [ + { type: 'paragraph', content: [{ type: 'text', text: `GitHub Issue: ${issue.html_url}` }] }, + { type: 'paragraph', content: [{ type: 'text', text: `Author: ${issue.user.login}` }] } + ]; + + switch (templateType) { + case 'epic': + descContent.push( + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '목표' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('목표') }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '범위' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('범위 / Not-in-scope') }] } + ); + break; + case 'story': + descContent.push( + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '배경' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('배경') }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '수용 기준(AC)' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('수용 기준\\(AC\\)') }] } + ); + break; + case 'cr': + descContent.push( + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '제안 변경 사항' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('제안 변경 사항') }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '영향도' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('영향도') }] } + ); + break; + case 'spike': + descContent.push( + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '타임박스' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('타임박스') }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '핵심 질문' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('핵심 질문') }] } + ); + break; + default: + descContent.push( + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '연결된 Story/Epic' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('연결된 Story/Epic') }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '작업 범위' }] }, + { type: 'paragraph', content: [{ type: 'text', text: parseSection('작업 범위') }] } + ); + } + + const jiraResponse = await fetch( + `${process.env.JIRA_BASE_URL}/rest/api/3/issue`, + { + method: 'POST', + headers: { + 'Authorization': `Basic ${Buffer.from(`${process.env.JIRA_USER_EMAIL}:${process.env.JIRA_API_TOKEN}`).toString('base64')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + fields: { + project: { key: 'MESP' }, + summary: `[Issue-#${issue.number}] ${issue.title}`, + description: { + type: 'doc', + version: 1, + content: descContent + }, + issuetype: { name: jiraType } + } + }) + } + ); + + const jiraData = await jiraResponse.json(); + + if (jiraData.key) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `Jira: [${jiraData.key}](${process.env.JIRA_BASE_URL}/browse/${jiraData.key})` + }); + console.log(`Created Jira ${jiraData.key} for Issue #${issue.number}`); + } else { + console.log(`Failed to create Jira for Issue #${issue.number}:`, jiraData); + } + + await new Promise(resolve => setTimeout(resolve, 1000)); + } \ No newline at end of file diff --git a/.github/workflows/github-jira-pr-sync.yml b/.github/workflows/github-jira-pr-sync.yml new file mode 100644 index 0000000..de42f0b --- /dev/null +++ b/.github/workflows/github-jira-pr-sync.yml @@ -0,0 +1,134 @@ +name: PR-Jira Sync + +on: + pull_request_target: + types: [opened, reopened] + branches: + - develop + - main + + # 수동 트리거 추가 + workflow_dispatch: + inputs: + process_all_open_prs: + description: 'Process all open PRs' + type: boolean + default: true + +jobs: + sync-pr-to-jira: + runs-on: ubuntu-latest + + steps: + - name: Login to Jira + uses: atlassian/gajira-login@v3 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + + - name: Determine Issue Type + id: issue-type + run: | + TITLE='${{ github.event.pull_request.title }}' + + if echo "$TITLE" | grep -qiE "^\[EPIC\]"; then + echo "type=Epic" >> $GITHUB_OUTPUT + elif echo "$TITLE" | grep -qiE "^\[STORY\]"; then + echo "type=Story" >> $GITHUB_OUTPUT + elif echo "$TITLE" | grep -qiE "^fix:|^hotfix:"; then + echo "type=Bug" >> $GITHUB_OUTPUT + else + echo "type=Task" >> $GITHUB_OUTPUT + fi + + - name: Convert to Jira Syntax + uses: peter-evans/jira2md@v1 + id: md2jira + with: + input-text: | + h3. GitHub PR + ${{ github.event.pull_request.html_url }} + + h3. Author + ${{ github.event.pull_request.user.login }} + + h3. Branch + ${{ github.head_ref }} -> ${{ github.base_ref }} + + ---- + ${{ github.event.pull_request.body }} + mode: md2jira + + - name: Create Jira Issue + id: create-jira + uses: atlassian/gajira-create@v3 + with: + project: MESP + issuetype: ${{ steps.issue-type.outputs.type }} + summary: '[PR-#${{ github.event.pull_request.number }}] ${{ github.event.pull_request.title }}' + description: '${{ steps.md2jira.outputs.output-text }}' + + - name: Add Jira Link Comment + uses: actions/github-script@v7 + with: + script: | + const jiraKey = '${{ steps.create-jira.outputs.issue }}'; + const jiraUrl = '${{ secrets.JIRA_BASE_URL }}/browse/' + jiraKey; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: `Jira: [${jiraKey}](${jiraUrl})` + }); + + # 추가: 열린 PR 일괄 처리 + sync-all-open-prs: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - name: Get Open PRs + id: get-prs + uses: actions/github-script@v7 + with: + script: | + const prs = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open' + }); + return prs.data; + + - name: Login to Jira + uses: atlassian/gajira-login@v3 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + + - name: Process Each PR + uses: actions/github-script@v7 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + with: + script: | + const prs = ${{ steps.get-prs.outputs.result }}; + + for (const pr of prs) { + // 이미 Jira 링크가 있는지 확인 + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number + }); + + const hasJiraLink = comments.data.some(c => c.body.includes('Jira:')); + if (hasJiraLink) { + console.log(`PR #${pr.number} already has Jira link, skipping`); + continue; + } + + console.log(`Processing PR #${pr.number}: ${pr.title}`); + // 여기서 Jira 생성 로직 호출 + } \ No newline at end of file