ADR: ADR 운영 원칙(문서 구조/넘버링/라벨/자동화) #6
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: ADR Auto Create from Issue | |
| on: | |
| issues: | |
| types: [labeled] | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| issues: read | |
| concurrency: | |
| group: adr-create-${{ github.event.issue.number }} | |
| cancel-in-progress: false | |
| jobs: | |
| create-adr-md: | |
| if: github.event.label.name == 'adr:accepted' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Extract issue fields | |
| id: x | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const issue = context.payload.issue; | |
| const number = issue.number; | |
| const titleText = (issue.title || "").trim(); | |
| const body = issue.body || ""; | |
| // heading escape + storage path만 파싱(디렉토리 결정을 위해 최소한만) | |
| const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| function extractField(heading) { | |
| const h = esc(String(heading).trim()); | |
| const re = new RegExp(`^###\\s+${h}\\s*\\n([\\s\\S]*?)(?=\\n###\\s|$)`, 'm'); | |
| const m = body.match(re); | |
| return m ? m[1].trim() : ""; | |
| } | |
| const normalize = (v) => { | |
| const s = (v || "").trim(); | |
| return /^_?No response_?$/i.test(s) ? "" : s; | |
| }; | |
| // 저장 위치만 읽어서 디렉토리/ID prefix 결정 | |
| const STORAGE_HEADING = "저장 위치 (Storage Path)"; | |
| let storagePath = normalize(extractField(STORAGE_HEADING)) || "org"; | |
| storagePath = storagePath.split("\n").map(s=>s.trim()).filter(Boolean)[0] || "org"; | |
| const mapDir = (v) => { | |
| switch (v) { | |
| case 'org': return { dir: 'docs/adr/org', idPrefix: 'O-ADR' }; | |
| case 'vref-be': return { dir: 'docs/adr/repos/vref-be', idPrefix: 'R-ADR-vref-be' }; | |
| case 'vref-fe': return { dir: 'docs/adr/repos/vref-fe', idPrefix: 'R-ADR-vref-fe' }; | |
| case 'vref-config': return { dir: 'docs/adr/repos/vref-config', idPrefix: 'R-ADR-vref-config' }; | |
| case 'vref-infra': return { dir: 'docs/adr/repos/vref-infra', idPrefix: 'R-ADR-vref-infra' }; | |
| case 'etc': return { dir: 'docs/adr/repos/etc', idPrefix: 'R-ADR-etc' }; | |
| default: return { dir: 'docs/adr/org', idPrefix: 'O-ADR' }; | |
| } | |
| }; | |
| const { dir, idPrefix } = mapDir(storagePath); | |
| // 파일명/ID/날짜 | |
| const now = new Date(); | |
| const year = now.getFullYear(); | |
| const date = now.toISOString().slice(0,10); | |
| const adrId = `${idPrefix}-${year}-${String(number).padStart(4,'0')}`; | |
| // slug는 제목 기준(요약 미사용) | |
| const slug = (titleText || `${storagePath}-proposal`) | |
| .toLowerCase() | |
| .replace(/[^a-z0-9가-힣\s-]/g, '') | |
| .replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') | |
| .slice(0, 80) || 'proposal'; | |
| const filename = `${dir}/${adrId}-${slug}.md`; | |
| // 메타 데이터 + 이슈 바디 원문 유지 + 하단 안내(표시용) | |
| const md = `# ${adrId}: ${titleText} | |
| - **Status**: Accepted | |
| - **Date**: ${date} | |
| - **Discussion**: #${number} | |
| - **Scope**: ${storagePath === 'org' ? 'Org' : 'Repo'} | |
| - **Related**: - | |
| --- | |
| ${body.trim()} | |
| --- | |
| > Tracking issue: #${number} | |
| `; | |
| core.setOutput('filename', filename); | |
| core.setOutput('content', md); | |
| core.setOutput('branch', `adr/${adrId}`); | |
| - name: Create branch & commit | |
| env: | |
| FILENAME: ${{ steps.x.outputs.filename }} | |
| CONTENT: ${{ steps.x.outputs.content }} | |
| BRANCH: ${{ steps.x.outputs.branch }} | |
| run: | | |
| set -euo pipefail | |
| git fetch origin --prune | |
| # 항상 최신 main을 베이스로 시작 | |
| git checkout -B tmp/adr-base origin/main | |
| # 작업 브랜치 만들기/갱신 | |
| if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then | |
| git checkout -B "$BRANCH" "origin/$BRANCH" | |
| git rebase --rebase-merges --onto origin/main "$(git merge-base origin/main "$BRANCH")" "$BRANCH" || true | |
| else | |
| git checkout -B "$BRANCH" origin/main | |
| fi | |
| # 혹시 스테이징되어 있을지 모를 워크플로 변경 제거(보수적) | |
| git restore --staged .github/workflows || true | |
| git checkout -- .github/workflows || true | |
| mkdir -p "$(dirname "$FILENAME")" | |
| printf "%s" "$CONTENT" > "$FILENAME" | |
| # 상태 확인(디버그) | |
| git status --porcelain | |
| git add "$FILENAME" | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| # 변경 없으면 커밋 생략 | |
| git diff --cached --quiet || git commit -m "chore(adr): add/update ${FILENAME}" | |
| # 기본 푸시 → 실패 시 with-lease로 폴백 | |
| git push --set-upstream origin "$BRANCH" || git push --force-with-lease origin "$BRANCH" | |
| - name: Open PR | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const head = process.env.BRANCH; | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const n = context.issue.number; | |
| const compareUrl = `https://github.com/${owner}/${repo}/compare/main...${encodeURIComponent(head)}?expand=1`; | |
| const prBody = [ | |
| '이슈 라벨 트리거로 자동 생성된 ADR 초안입니다. 리뷰 후 머지해주세요.', | |
| '', | |
| `Closes #${n}` | |
| ].join('\n'); | |
| try { | |
| const { data: pr } = await github.rest.pulls.create({ | |
| owner, repo, title: `ADR: ${head}`, head, base: 'main', body: prBody | |
| }); | |
| core.info(`PR #${pr.number} opened`); | |
| } catch (e) { | |
| core.warning(`PR auto-create failed: ${e.message}`); | |
| await github.rest.issues.createComment({ | |
| owner, repo, issue_number: n, | |
| body: [ | |
| '🔒 PR 자동 생성이 차단되어 실패했습니다.', | |
| `브랜치는 준비되어 있습니다: \`${head}\`.`, | |
| `➡️ [여기](${compareUrl})를 눌러 수동으로 PR을 열어주세요.`, | |
| '', | |
| `PR 본문에 \`Closes #${n}\`를 포함하면 머지 시 이슈가 자동 종료됩니다.` | |
| ].join('\n') | |
| }); | |
| } | |
| env: | |
| BRANCH: ${{ steps.x.outputs.branch }} |