Skip to content

ADR: ADR 운영 원칙(문서 구조/넘버링/라벨/자동화) #6

ADR: ADR 운영 원칙(문서 구조/넘버링/라벨/자동화)

ADR: ADR 운영 원칙(문서 구조/넘버링/라벨/자동화) #6

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