[Automation] Jira → GitHub (사용자 매핑)

김동섭·2025년 11월 5일

Automation

목록 보기
6/8
post-thumbnail

이번 글에서는 Jira에서의 "담당자"GitHub에서의 "Assignees"를 연동, 즉 사용자 매핑을 해보도록 하겠습니다.

1. Jira Automation 규칙 수정

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에서 해당 이메일 주소와 깃허브 닉네임을 매핑시킬 예정입니다.


2. GitHub Json 파일 작성

.github 디렉터리 안에 매핑이 된 json 파일을 작성해줍니다.

경로 : [.github/jira-git-user-mapping.json]
// 좌측 : Jira Email & 우측 : GitHub 이름


3. GitHub Actions 수정

저희가 이전에 작성했던 워크플로우 jira-issue-created.ymljira-issue-updated.yml에 매핑 스크립트문을 추가해줍니다.

Job 추가 : Assign mapped GitHub users

[jira-issue-created.yml]

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

[jira-issue-updated.yml]

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

4. 테스트 결과


profile
이것저것 모두 적어보는 공간

0개의 댓글