diff --git a/.github/workflows/github-jira-issue-sync.yml b/.github/workflows/github-jira-issue-sync.yml index bfb9501..5ea9cf1 100644 --- a/.github/workflows/github-jira-issue-sync.yml +++ b/.github/workflows/github-jira-issue-sync.yml @@ -331,4 +331,4 @@ jobs: } 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 index de42f0b..8b93f84 100644 --- a/.github/workflows/github-jira-pr-sync.yml +++ b/.github/workflows/github-jira-pr-sync.yml @@ -7,7 +7,6 @@ on: - develop - main - # 수동 트리거 추가 workflow_dispatch: inputs: process_all_open_prs: @@ -15,61 +14,118 @@ on: type: boolean default: true +permissions: + issues: write + pull-requests: write + contents: read + jobs: sync-pr-to-jira: + if: github.event_name == 'pull_request_target' 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.pull_request.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: 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 + - name: Parse PR Template + if: steps.check-jira.outputs.result == 'false' + id: parse + uses: actions/github-script@v7 with: - input-text: | - h3. GitHub PR - ${{ github.event.pull_request.html_url }} + script: | + const title = context.payload.pull_request.title; + const body = context.payload.pull_request.body || ''; - h3. Author - ${{ github.event.pull_request.user.login }} + // 제목에서 type 파싱: feat(scope): summary 또는 fix: summary + const typeMatch = title.match(/^(\w+)(?:\([^)]*\))?:/); + const type = typeMatch ? typeMatch[1].toLowerCase() : 'task'; - h3. Branch - ${{ github.head_ref }} -> ${{ github.base_ref }} + // type → Jira Issue Type 매핑 + const typeMap = { + 'feat': 'Story', + 'feature': 'Story', + 'fix': 'Bug', + 'hotfix': 'Bug', + 'bug': 'Bug', + 'epic': 'Epic', + 'docs': 'Task', + 'chore': 'Task', + 'refactor': 'Task', + 'test': 'Task', + 'style': 'Task', + 'perf': 'Task' + }; + const jiraType = typeMap[type] || 'Task'; + + // 본문에서 섹션 파싱 + const sections = {}; + const sectionNames = ['목적', '변경 요약', '수용 기준 검증', '브레이킹/마이그레이션', '테스트', '참조']; + + for (const name of sectionNames) { + const regex = new RegExp(`${name}\\n([\\s\\S]*?)(?=\\n(?:${sectionNames.join('|')})|$)`); + const match = body.match(regex); + if (match) { + sections[name] = match[1].trim(); + } + } + + // refs #123 에서 이슈 번호 추출 + const issueRef = title.match(/refs #(\d+)/i); + const relatedIssue = issueRef ? issueRef[1] : null; - ---- - ${{ github.event.pull_request.body }} - mode: md2jira + core.setOutput('jira_type', jiraType); + core.setOutput('purpose', sections['목적'] || ''); + core.setOutput('changes', sections['변경 요약'] || ''); + core.setOutput('acceptance', sections['수용 기준 검증'] || ''); + core.setOutput('related_issue', relatedIssue || ''); - name: Create Jira Issue + if: steps.check-jira.outputs.result == 'false' id: create-jira uses: atlassian/gajira-create@v3 with: project: MESP - issuetype: ${{ steps.issue-type.outputs.type }} + issuetype: ${{ steps.parse.outputs.jira_type }} summary: '[PR-#${{ github.event.pull_request.number }}] ${{ github.event.pull_request.title }}' - description: '${{ steps.md2jira.outputs.output-text }}' + description: | + 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 }} + + h3. 목적 + ${{ steps.parse.outputs.purpose }} + + h3. 변경 요약 + ${{ steps.parse.outputs.changes }} + + h3. 수용 기준 + ${{ steps.parse.outputs.acceptance }} - name: Add Jira Link Comment + if: steps.check-jira.outputs.result == 'false' uses: actions/github-script@v7 with: script: | @@ -83,40 +139,44 @@ jobs: body: `Jira: [${jiraKey}](${jiraUrl})` }); - # 추가: 열린 PR 일괄 처리 + # 기존 PR 일괄 처리 sync-all-open-prs: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - - name: Get Open PRs - id: get-prs + - name: Process All Open 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 }}; + const prs = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100 + }); + + console.log(`Found ${prs.data.length} open PRs`); - for (const pr of prs) { - // 이미 Jira 링크가 있는지 확인 + const typeMap = { + 'feat': 'Story', + 'feature': 'Story', + 'fix': 'Bug', + 'hotfix': 'Bug', + 'bug': 'Bug', + 'epic': 'Epic', + 'docs': 'Task', + 'chore': 'Task', + 'refactor': 'Task', + 'test': 'Task', + 'style': 'Task', + 'perf': 'Task' + }; + + for (const pr of prs.data) { const comments = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, @@ -130,5 +190,67 @@ jobs: } console.log(`Processing PR #${pr.number}: ${pr.title}`); - // 여기서 Jira 생성 로직 호출 + + // 제목에서 type 파싱 + const typeMatch = pr.title.match(/^(\w+)(?:\([^)]*\))?:/); + const type = typeMatch ? typeMatch[1].toLowerCase() : 'task'; + const jiraType = typeMap[type] || 'Task'; + + // 본문 파싱 + const body = pr.body || ''; + const sections = {}; + const sectionNames = ['목적', '변경 요약', '수용 기준 검증']; + + for (const name of sectionNames) { + const regex = new RegExp(`${name}\\n([\\s\\S]*?)(?=\\n(?:목적|변경 요약|수용 기준 검증|브레이킹|테스트|참조)|$)`); + const match = body.match(regex); + if (match) sections[name] = match[1].trim(); + } + + 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: `[PR-#${pr.number}] ${pr.title}`, + description: { + type: 'doc', + version: 1, + content: [ + { type: 'paragraph', content: [{ type: 'text', text: `GitHub PR: ${pr.html_url}` }] }, + { type: 'paragraph', content: [{ type: 'text', text: `Author: ${pr.user.login}` }] }, + { type: 'paragraph', content: [{ type: 'text', text: `Branch: ${pr.head.ref} → ${pr.base.ref}` }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '목적' }] }, + { type: 'paragraph', content: [{ type: 'text', text: sections['목적'] || '-' }] }, + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '변경 요약' }] }, + { type: 'paragraph', content: [{ type: 'text', text: sections['변경 요약'] || '-' }] } + ] + }, + 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: pr.number, + body: `Jira: [${jiraData.key}](${process.env.JIRA_BASE_URL}/browse/${jiraData.key})` + }); + console.log(`Created Jira ${jiraData.key} for PR #${pr.number}`); + } else { + console.log(`Failed to create Jira for PR #${pr.number}:`, jiraData); + } + + await new Promise(resolve => setTimeout(resolve, 1000)); } \ No newline at end of file