featrue : OPIC 말하기연습 전체 세션에 대한 피드백 리포트 화면 구현 #87
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
| name: PR-Jira Sync | |
| on: | |
| pull_request_target: | |
| types: [opened, reopened, edited, closed] | |
| branches: | |
| - develop | |
| - main | |
| workflow_dispatch: | |
| inputs: | |
| process_all_open_prs: | |
| description: 'Process all open PRs' | |
| 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: Parse PR Template | |
| if: steps.check-jira.outputs.result == 'false' | |
| id: parse | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const title = context.payload.pull_request.title; | |
| const body = context.payload.pull_request.body || ''; | |
| // 제목에서 type 파싱: feat(scope): summary 또는 fix: summary | |
| const typeMatch = title.match(/^(\w+)(?:\([^)]*\))?:/); | |
| const type = typeMatch ? typeMatch[1].toLowerCase() : 'task'; | |
| // 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; | |
| 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.parse.outputs.jira_type }} | |
| summary: '[PR-#${{ github.event.pull_request.number }}] ${{ github.event.pull_request.title }}' | |
| 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: | | |
| 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 수정 시 Jira 업데이트 | |
| update-jira-on-pr-edit: | |
| if: github.event_name == 'pull_request_target' && github.event.action == 'edited' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Get Jira Key from Comments | |
| id: get-jira-key | |
| 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 | |
| }); | |
| for (const comment of comments.data) { | |
| const match = comment.body.match(/Jira: \[([A-Z]+-\d+)\]/); | |
| if (match) { | |
| core.setOutput('jira_key', match[1]); | |
| return match[1]; | |
| } | |
| } | |
| core.setOutput('jira_key', ''); | |
| return ''; | |
| - name: Parse Updated PR | |
| if: steps.get-jira-key.outputs.jira_key != '' | |
| id: parse | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const pr = context.payload.pull_request; | |
| const body = pr.body || ''; | |
| const parseSection = (name, sectionNames) => { | |
| const regex = new RegExp(`${name}\\n([\\s\\S]*?)(?=\\n(?:${sectionNames.join('|')})|$)`); | |
| const match = body.match(regex); | |
| return match ? match[1].trim() : '-'; | |
| }; | |
| const sectionNames = ['목적', '변경 요약', '수용 기준 검증', '브레이킹/마이그레이션', '테스트', '참조']; | |
| const descContent = [ | |
| { 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: 'paragraph', content: [{ type: 'text', text: `Last Updated: ${new Date().toISOString()}` }] }, | |
| { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '목적' }] }, | |
| { type: 'paragraph', content: [{ type: 'text', text: parseSection('목적', sectionNames) }] }, | |
| { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '변경 요약' }] }, | |
| { type: 'paragraph', content: [{ type: 'text', text: parseSection('변경 요약', sectionNames) }] }, | |
| { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '수용 기준 검증' }] }, | |
| { type: 'paragraph', content: [{ type: 'text', text: parseSection('수용 기준 검증', sectionNames) }] } | |
| ]; | |
| core.setOutput('description', JSON.stringify(descContent)); | |
| - name: Update Jira Issue | |
| if: steps.get-jira-key.outputs.jira_key != '' | |
| 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 }} | |
| DESC_JSON: ${{ steps.parse.outputs.description }} | |
| with: | |
| script: | | |
| const jiraKey = '${{ steps.get-jira-key.outputs.jira_key }}'; | |
| const pr = context.payload.pull_request; | |
| const descContent = JSON.parse(process.env.DESC_JSON); | |
| const response = await fetch( | |
| `${process.env.JIRA_BASE_URL}/rest/api/3/issue/${jiraKey}`, | |
| { | |
| method: 'PUT', | |
| 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: { | |
| summary: `[PR-#${pr.number}] ${pr.title}`, | |
| description: { | |
| type: 'doc', | |
| version: 1, | |
| content: descContent | |
| } | |
| } | |
| }) | |
| } | |
| ); | |
| if (response.ok) { | |
| console.log(`✓ Updated Jira ${jiraKey}`); | |
| } else { | |
| const error = await response.text(); | |
| console.log(`✗ Failed to update Jira ${jiraKey}: ${error}`); | |
| } | |
| # PR 닫기/머지 시 Jira 상태 변경 | |
| close-jira-on-pr-close: | |
| if: github.event_name == 'pull_request_target' && github.event.action == 'closed' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Get Jira Key from Comments | |
| id: get-jira-key | |
| 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 | |
| }); | |
| for (const comment of comments.data) { | |
| const match = comment.body.match(/Jira: \[([A-Z]+-\d+)\]/); | |
| if (match) { | |
| core.setOutput('jira_key', match[1]); | |
| return match[1]; | |
| } | |
| } | |
| core.setOutput('jira_key', ''); | |
| return ''; | |
| - name: Get Jira Transitions | |
| if: steps.get-jira-key.outputs.jira_key != '' | |
| id: get-transitions | |
| 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 jiraKey = '${{ steps.get-jira-key.outputs.jira_key }}'; | |
| const response = await fetch( | |
| `${process.env.JIRA_BASE_URL}/rest/api/3/issue/${jiraKey}/transitions`, | |
| { | |
| headers: { | |
| 'Authorization': `Basic ${Buffer.from(`${process.env.JIRA_USER_EMAIL}:${process.env.JIRA_API_TOKEN}`).toString('base64')}`, | |
| 'Content-Type': 'application/json' | |
| } | |
| } | |
| ); | |
| const data = await response.json(); | |
| console.log('Available transitions:', JSON.stringify(data.transitions, null, 2)); | |
| const doneTransition = data.transitions.find(t => | |
| /done|완료|closed?|complete/i.test(t.name) | |
| ); | |
| if (doneTransition) { | |
| core.setOutput('transition_id', doneTransition.id); | |
| console.log(`Found transition: ${doneTransition.name} (${doneTransition.id})`); | |
| } else { | |
| core.setOutput('transition_id', ''); | |
| console.log('No matching transition found'); | |
| } | |
| - name: Transition Jira to Done | |
| if: steps.get-jira-key.outputs.jira_key != '' && steps.get-transitions.outputs.transition_id != '' | |
| 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 jiraKey = '${{ steps.get-jira-key.outputs.jira_key }}'; | |
| const transitionId = '${{ steps.get-transitions.outputs.transition_id }}'; | |
| const response = await fetch( | |
| `${process.env.JIRA_BASE_URL}/rest/api/3/issue/${jiraKey}/transitions`, | |
| { | |
| 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({ | |
| transition: { id: transitionId } | |
| }) | |
| } | |
| ); | |
| if (response.ok) { | |
| console.log(`✓ Transitioned Jira ${jiraKey} to Done`); | |
| } else { | |
| const error = await response.text(); | |
| console.log(`✗ Failed to transition Jira ${jiraKey}: ${error}`); | |
| } | |
| - name: Add Close Comment to Jira | |
| if: steps.get-jira-key.outputs.jira_key != '' | |
| 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 jiraKey = '${{ steps.get-jira-key.outputs.jira_key }}'; | |
| const pr = context.payload.pull_request; | |
| const merged = pr.merged ? 'merged' : 'closed without merge'; | |
| await fetch( | |
| `${process.env.JIRA_BASE_URL}/rest/api/3/issue/${jiraKey}/comment`, | |
| { | |
| 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({ | |
| body: { | |
| type: 'doc', | |
| version: 1, | |
| content: [ | |
| { | |
| type: 'paragraph', | |
| content: [ | |
| { type: 'text', text: `GitHub PR ${merged} by ${pr.user.login} at ${new Date().toISOString()}` } | |
| ] | |
| } | |
| ] | |
| } | |
| }) | |
| } | |
| ); | |
| # 기존 PR 일괄 처리 | |
| sync-all-open-prs: | |
| if: github.event_name == 'workflow_dispatch' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Process All Open PRs | |
| 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 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`); | |
| 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, | |
| 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}`); | |
| // 제목에서 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)); | |
| } |