Skip to content

[FIX] 끝말잇기 게임 버그 수정 및 UI 개선 #75

[FIX] 끝말잇기 게임 버그 수정 및 UI 개선

[FIX] 끝말잇기 게임 버그 수정 및 UI 개선 #75

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));
}