
이번 글에서는 Jira에서의 "담당자"와 GitHub에서의 "Assignees"를 연동, 즉 사용자 매핑을 해보도록 하겠습니다.
Jira의 자동화에서 한번에 보낼 수 있는 "client_payload"의 개수는 10개 입니다. 만약 10개를 초과한다면 아래와 같이 "422 Error" 가 발생합니다.
그래서 이전에 저희 작성했던 규칙들 중 업데이트 규칙에서는 사용자 정의 데이터 속 "client_payload"를 정확하게 10개에 맞췄기 때문에 1개를 빼줘야 합니다.
그럼 무엇을 저희가 제외시켜야 하나? 바로 "url"입니다. url은 최초 생성 후에 절대 불변하기 때문에 제외시켜도 무방합니다!
// 만약 "client_payload"의 개수가 10개를 초과할 경우 발생할 수 있는 Error
웹 요청을 게시할 수 없음 - 받은 HTTP 상태 응답:
422
Error found in the HTTP body response:
{"message":"Invalid request.\n\nNo more than 10 properties are allowed; 12 were supplied.","documentation_url":"https://docs.github.com/rest/repos/repos#create-a-repository-dispatch-event","status":"422"}
이제 저희가 이때까지 정의해놓은 모든 규칙의 "웹 요청 전송"의 "사용자 정의 데이터" 1개의 json 데이터를 추가 해주세요!
"assigneeEmail": "{{issue.assignee.emailAddress}}"
위 데이터는 Jira의 이메일 주소이며 저희는 이제 GitHub에서 해당 이메일 주소와 깃허브 닉네임을 매핑시킬 예정입니다.

.github 디렉터리 안에 매핑이 된 json 파일을 작성해줍니다.
경로 : [.github/jira-git-user-mapping.json]
// 좌측 : Jira Email & 우측 : GitHub 이름

저희가 이전에 작성했던 워크플로우 jira-issue-created.yml와 jira-issue-updated.yml에 매핑 스크립트문을 추가해줍니다.
Job 추가 : Assign mapped GitHub users
name: Create GitHub Issue from Jira
on:
repository_dispatch:
types: [jira-issue]
permissions:
contents: read
issues: write
jobs:
create-issue:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Render template and create issue
id: create_issue
uses: actions/github-script@v7
with:
# result를 문자열로 돌려받아소 다음 스텝에서 steps.create_issue.outputs.result로 사용할 예정 ㅇㅇ
result-encoding: string
script: |
const fs = require('fs');
const path = require('path');
const p = context.payload.client_payload || {};
const kind = (p.kind || '').toLowerCase();
const esc = (s) => (s ?? '').toString();
const nz = (s, fallback='') => { const v = (s ?? '').toString().trim(); return v.length ? v : fallback; };
const decodeMaybe = (s) => { const str = esc(s); try { return /%[0-9A-Fa-f]{2}/.test(str) ? decodeURIComponent(str) : str; } catch { return str; } };
const mapping = {
story: '.github/ISSUE_TEMPLATE/automation-story.md',
bug: '.github/ISSUE_TEMPLATE/automation-bug.md',
task: '.github/ISSUE_TEMPLATE/automation-task.md'
};
const file = mapping[kind] || mapping.task;
const templatePath = path.join(process.cwd(), file);
if (!fs.existsSync(templatePath)) {
core.setFailed(`Template not found: ${file}`);
return;
}
const tpl = fs.readFileSync(templatePath, 'utf8');
const key = esc(p.key);
const url = esc(p.url);
const summary = esc(p.summary);
const description = esc(p.description);
const priority = esc(p.priority);
const duedate = nz(p.duedate, '미정');
const checklist = decodeMaybe(p.checklist);
let body = tpl
.replaceAll('{{issue.key}}', key)
.replaceAll('{{key}}', key)
.replaceAll('{{issue.url}}', url)
.replaceAll('{{url}}', url)
.replaceAll('{{issue.summary}}', summary)
.replaceAll('{{summary}}', summary)
.replaceAll('{{description}}', description)
.replaceAll('{{priority}}', priority)
.replaceAll('{{issue.duedate}}', duedate)
.replaceAll('{{duedate}}', duedate)
.replaceAll('{{checklist}}', checklist || '');
if (checklist && !tpl.includes('{{checklist}}')) {
body += `\n\n---\n\n## ✅ To-Do\n${checklist}\n`;
}
const allowed = new Set(['Feature','Refactor','Documentation']);
let labels = [];
if (kind === 'story') labels = ['Story'];
else if (kind === 'bug') labels = ['Bug'];
else {
let incoming = [];
if (Array.isArray(p.labels)) incoming = p.labels;
else if (typeof p.labels === 'string') {
try { const parsed = JSON.parse(p.labels); if (Array.isArray(parsed)) incoming = parsed; }
catch { incoming = p.labels.split(',').map(s=>s.trim()).filter(Boolean); }
}
labels = incoming.filter(v => allowed.has(v));
if (labels.length === 0) labels = ['Task'];
}
const title = `[${key}] ${summary}`;
const res = await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body,
labels
});
core.info(`Created issue #${res.data.number}`);
// 아까 위에서 말했던 대로 이 값을 다음 스텝에서 steps.create_issue.outputs.result로 받음!
return String(res.data.number);
- name: Assign mapped GitHub users
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = require('path');
const p = context.payload.client_payload || {};
const issue_number = Number('${{ steps.create_issue.outputs.result }}');
if (!issue_number) {
core.info('No issue number. Skip assignees.');
return;
}
// 매핑 파일 읽기
const mapPath = path.join(process.cwd(), '.github/jira-git-user-mapping.json');
if (!fs.existsSync(mapPath)) {
core.info('Mapping file not found. Skip assigning.');
return;
}
let mapping = {};
try {
mapping = JSON.parse(fs.readFileSync(mapPath, 'utf8'));
} catch (e) {
core.warning(`Failed to parse mapping file: ${e.message}`);
return;
}
// Jira payload에서 이메일 우선
const email = (p.assigneeEmail || '').trim().toLowerCase();
const assignees = [];
if (email && mapping[email]) {
assignees.push(mapping[email]);
} else {
core.info(`No mapping for assigneeEmail "${email}"`);
}
if (assignees.length === 0) {
core.info('No mapped assignees. Skip.');
return;
}
try {
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number,
assignees
});
core.info(`Assigned: ${assignees.join(', ')}`);
} catch (e) {
core.warning(`Failed to add assignees: ${e.status} ${e.message}`);
}
name: Update issue from Jira
on:
repository_dispatch:
types: [jira-issue-updated]
permissions:
contents: read
issues: write
jobs:
update-issue:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Render via template and safe patch (with Jira fetch fallback)
uses: actions/github-script@v7
env:
JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }}
JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }}
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
with:
script: |
const fs = require('fs');
const path = require('path');
// Node20은 global fetch가 존재하니 만약 없을 경우를 대비해서 require fallback
const fetch = global.fetch || ((...args) => import('node-fetch').then(({default: f}) => f(...args)));
const p = context.payload.client_payload || {};
const esc = (s) => (s ?? '').toString();
const nz = (s, f='') => { const v = esc(s).trim(); return v ? v : f; };
const has = (s) => !!esc(s).trim();
// urlEncoded(%)이면 복원 (+ → 공백)
const decodeMaybe = (s) => {
const str = esc(s);
try {
const decoded = /%[0-9A-Fa-f]{2}/.test(str) ? decodeURIComponent(str) : str;
return decoded.replace(/\+/g, ' ');
} catch { return str; }
};
async function fetchJiraDescriptionHTML(issueKey) {
const base = (process.env.JIRA_BASE_URL || process.env.JIRA_BASE || '').replace(/\/+$/,'');
if (!base || !issueKey) return '';
const url = `${base}/rest/api/3/issue/${encodeURIComponent(issueKey)}?expand=renderedFields&fields=description`;
const resp = await fetch(url, {
headers: {
'Accept': 'application/json',
'Authorization': 'Basic ' + Buffer.from(`${process.env.JIRA_USER_EMAIL}:${process.env.JIRA_API_TOKEN}`).toString('base64')
}
});
if (!resp.ok) {
core.info(`Jira GET failed: ${resp.status} ${await resp.text()}`);
return '';
}
const data = await resp.json();
return (data.renderedFields && data.renderedFields.description) ? data.renderedFields.description : '';
}
// 체크리스트를 깃허브 체크박스로 정규화
function normalizeChecklist(input) {
const raw = decodeMaybe(input);
if (!has(raw)) return '';
const lines = raw.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
return lines.map(line => {
// 이미 - [ ] 형태면 유지, 아니라면 변환
if (/^- \[.\] /.test(line)) return line;
// Jira의 [] 접두 등 다양한 패턴을 - [ ] 로 매핑
if (/^\[\s?\]\s*/.test(line)) return line.replace(/^\[\s?\]\s*/, '- [ ] ');
if (/^[\*\-]\s+/.test(line)) return line.replace(/^[\*\-]\s+/, '- [ ] ');
return `- [ ] ${line}`;
}).join('\n');
}
// 0) 필수값: Jira에서의 "GitHub Issue Number"
const issue_number = Number(p.issue_number);
if (!issue_number) { core.setFailed('Missing issue_number in payload'); return; }
const owner = context.repo.owner;
const repo = context.repo.repo;
// 1) 현재 GitHub Issue 읽기(빈값 덮어쓰기 방지)
const cur = await github.rest.issues.get({ owner, repo, issue_number });
const curTitle = cur.data.title || '';
const curBody = cur.data.body || '';
const curLabels = (cur.data.labels || []).map(l => typeof l === 'string' ? l : l.name).filter(Boolean);
// 2) kind 정규화 (한글/영문)
const rawKind = esc(p.kind).toLowerCase();
const isStory = /^(story|스토리)/.test(rawKind);
const isBug = /^(bug|버그)/.test(rawKind);
const kind = isStory ? 'story' : (isBug ? 'bug' : 'task');
// 3) 템플릿 선택
const mapping = {
story: '.github/ISSUE_TEMPLATE/automation-story.md',
bug: '.github/ISSUE_TEMPLATE/automation-bug.md',
task: '.github/ISSUE_TEMPLATE/automation-task.md'
};
const file = mapping[kind] || mapping.task;
const templatePath = path.join(process.cwd(), file);
const hasTemplate = fs.existsSync(templatePath);
const tpl = hasTemplate ? fs.readFileSync(templatePath, 'utf8') : '';
// 4) 값 준비 + Jira 보강
const key = esc(p.key);
const urlLink = esc(p.url);
const summary = esc(p.summary);
let description = esc(p.description);
const priority = esc(p.priority);
const duedate = nz(p.duedate, '미정');
const checklistMD = normalizeChecklist(p.checklist);
if (!description.trim() && key) {
const html = await fetchJiraDescriptionHTML(key);
if (has(html)) description = html;
}
// 5) 템플릿 바인딩
let producedBody = '';
if (hasTemplate) {
let body = tpl
.replaceAll('{{issue.key}}', key)
.replaceAll('{{key}}', key)
.replaceAll('{{issue.url}}', urlLink)
.replaceAll('{{url}}', urlLink)
.replaceAll('{{issue.summary}}', summary)
.replaceAll('{{summary}}', summary)
.replaceAll('{{description}}', has(description) ? description : '')
.replaceAll('{{priority}}', has(priority) ? priority : '')
.replaceAll('{{issue.duedate}}', duedate)
.replaceAll('{{duedate}}', duedate)
.replaceAll('{{checklist}}', has(checklistMD) ? checklistMD : '');
if (has(checklistMD) && !tpl.includes('{{checklist}}')) {
body += `\n\n---\n\n### ✅ To-Do CheckList\n${checklistMD}\n`;
}
producedBody = body.trim();
} else {
const parts = [];
if (has(description)) {
parts.push('## 📄 이슈 개요 (Description)', description, ''); // HTML 그대로
}
if (has(checklistMD)) {
parts.push('---', '', '### ✅ To-Do CheckList', checklistMD, '');
}
if (has(priority)) {
parts.push('---', '', '### 🎯 우선순위', `<ins>${priority}</ins>`, '');
}
if (has(duedate)) {
parts.push('', '### 📅 기한', `*${duedate}*`, '');
}
if (has(key) || has(urlLink)) {
const link = has(urlLink) ? `[**${key}**](${urlLink})` : key;
parts.push('', '### 🔗 Jira Link', link);
}
producedBody = parts.join('\n').trim();
}
// 6) 본문 교체 조건 : 값이 오거나 현재 비어있을 때만 교체
const shouldReplaceBody =
has(description) || has(checklistMD) || has(priority) || has(duedate) || has(key) || has(urlLink) || !curBody;
const nextBody = shouldReplaceBody ? (producedBody || curBody) : curBody;
// 7) 제목 : 값이 비면 기존 유지
const nextTitle = (has(key) || has(summary))
? `[${nz(key, curTitle)}] ${nz(summary, curTitle)}`
: curTitle;
// 8) 라벨 계산 (Story/Bug 고정, Task는 3종만) + (비면 기존 유지)
const allowed = new Set(['Feature','Refactor','Documentation','Story','Bug']);
let computed = [];
if (isStory) computed = ['Story'];
else if (isBug) computed = ['Bug'];
else {
let incoming = [];
if (Array.isArray(p.labels)) incoming = p.labels;
else if (typeof p.labels === 'string') {
try {
const parsed = JSON.parse(p.labels);
if (Array.isArray(parsed)) incoming = parsed;
else incoming = esc(p.labels).split(',').map(s=>s.trim()).filter(Boolean);
} catch {
incoming = esc(p.labels).split(',').map(s=>s.trim()).filter(Boolean);
}
}
const toTitle = s => s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : s;
computed = incoming.map(toTitle).filter(v => allowed.has(v) && v !== 'Story' && v !== 'Bug');
}
const setLabels = computed.length > 0; // 비면 기존 라벨 유지
// 9) 패치
if (nextTitle !== curTitle || nextBody !== curBody) {
await github.rest.issues.update({ owner, repo, issue_number, title: nextTitle, body: nextBody });
}
if (setLabels) {
await github.rest.issues.setLabels({ owner, repo, issue_number, labels: computed });
}
core.info(`Patched #${issue_number} titleChanged=${nextTitle!==curTitle} bodyChanged=${nextBody!==curBody} labelsSet=${setLabels} kind=${kind} computed=${JSON.stringify(computed)}`)
# Assignees 매핑 스텝 추가
- name: Assign mapped GitHub users
uses: actions/github-script@v7
env:
JIRA_GIT_USER_MAPPING_JSON: ${{ secrets.JIRA_GIT_USER_MAPPING_JSON }}
with:
script: |
const fs = require('fs');
const path = require('path');
// payload / issue_number
const p = context.payload.client_payload || {};
const issue_number = Number(p.issue_number);
if (!issue_number) {
core.info('No issue_number in payload. Skip assigning.');
return;
}
// 1) 매핑 로드 : 파일 우선 → 시크릿 폴백
const mapPath = path.join(process.cwd(), '.github/jira-git-user-mapping.json');
let raw = '';
if (fs.existsSync(mapPath)) {
raw = fs.readFileSync(mapPath, 'utf8');
core.info(`Mapping file found at ${mapPath} (len=${raw.length})`);
} else if (process.env.JIRA_GIT_USER_MAPPING_JSON) {
raw = process.env.JIRA_GIT_USER_MAPPING_JSON;
core.info(`Mapping loaded from secret JIRA_GIT_USER_MAPPING_JSON (len=${raw.length})`);
} else {
core.info('No mapping (file/secret). Skip assigning.');
return;
}
// 1-1) BOM 제거 + 파싱 + 키 소문자 정규화
let mapping = {};
try {
raw = raw.replace(/^\uFEFF/, '');
const parsed = JSON.parse(raw);
for (const [k, v] of Object.entries(parsed)) {
mapping[String(k).toLowerCase()] = String(v);
}
} catch (e) {
core.warning(`Failed to parse mapping JSON: ${e.message}`);
return;
}
// 2) Jira payload에서 식별자 뽑기
// 우선순위 : 이메일 → (옵션) accountId
const email = (p.assigneeEmail || '').trim().toLowerCase();
const accountId = (p.assigneeId || '').trim(); // 필요시 매핑에 accountId도 추가 가능
core.info(`assigneeEmail="${email || '(empty)'}" assigneeId="${accountId || '(empty)'}"`);
// 3) 매핑 조회
const assignees = [];
if (email && mapping[email]) assignees.push(mapping[email]);
else if (accountId && mapping[accountId.toLowerCase?.() ?? accountId]) assignees.push(mapping[accountId.toLowerCase?.() ?? accountId]);
if (assignees.length === 0) {
core.info('No mapped assignees. Skip.');
return;
}
// 4) GitHub에 배정 (권한 없는 계정이면 422)
try {
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number,
assignees
});
core.info(`Assigned: ${assignees.join(', ')}`);
} catch (e) {
core.warning(`Failed to add assignees: ${e.status} ${e.message}`);
}

