diff --git a/.github/workflows/dev-merge-close-issues.yml b/.github/workflows/dev-merge-close-issues.yml new file mode 100644 index 00000000..689a95c9 --- /dev/null +++ b/.github/workflows/dev-merge-close-issues.yml @@ -0,0 +1,97 @@ +name: Close issues on dev merge + +on: + pull_request: + types: [closed] + +permissions: + contents: read + issues: write + pull-requests: read + repository-projects: write + +jobs: + close-linked-issues: + if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev' + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.DECODED_GITHUB_TOKEN || github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_URL: ${{ github.event.pull_request.html_url }} + PROJECT_ID: PVT_kwDOCf1dEc4BUHn- + PROJECT_NUMBER: "3" + STATUS_FIELD_ID: PVTSSF_lADOCf1dEc4BUHn-zhBR8Fk + STATUS_DONE_OPTION_ID: "98236657" + steps: + - name: Extract closing issue references + id: refs + shell: bash + run: | + set -euo pipefail + body=$(jq -r '.pull_request.body // ""' "$GITHUB_EVENT_PATH") + issues=$( + printf '%s\n' "$body" | + perl -0777 -ne 'while (/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:https:\/\/github\.com\/decodedcorp\/decoded\/issues\/)?#?([0-9]+)/ig) { print "$1\n" }' | + sort -n -u + ) + + if [ -z "$issues" ]; then + echo "No closing issue references found in PR body." + echo "issues=" >> "$GITHUB_OUTPUT" + exit 0 + fi + + { + echo "issues<> "$GITHUB_OUTPUT" + + - name: Close issues and mark project Done + if: steps.refs.outputs.issues != '' + shell: bash + run: | + set -euo pipefail + owner="${REPO%%/*}" + repo="${REPO#*/}" + + while IFS= read -r issue; do + [ -z "$issue" ] && continue + + state=$(gh issue view "$issue" --repo "$REPO" --json state --jq '.state') + if [ "$state" = "OPEN" ]; then + gh issue close "$issue" \ + --repo "$REPO" \ + --comment "Closed automatically because PR #${PR_NUMBER} was merged into \`dev\`: ${PR_URL}" + else + echo "Issue #${issue} is already ${state}; skipping close." + fi + + item_id=$( + gh api graphql \ + -f query='query($owner:String!, $repo:String!, $number:Int!) { repository(owner:$owner, name:$repo) { issue(number:$number) { projectItems(first:20) { nodes { id project { ... on ProjectV2 { number } } } } } } }' \ + -f owner="$owner" \ + -f repo="$repo" \ + -F number="$issue" \ + --jq ".data.repository.issue.projectItems.nodes[] | select(.project.number == ${PROJECT_NUMBER}) | .id" | + head -n 1 + ) || true + + if [ -z "$item_id" ]; then + echo "Issue #${issue} is not in project #${PROJECT_NUMBER}; skipping project status update." + continue + fi + + if ! gh api graphql \ + -f query='mutation($project:ID!, $item:ID!, $field:ID!, $option:String!) { updateProjectV2ItemFieldValue(input:{projectId:$project, itemId:$item, fieldId:$field, value:{singleSelectOptionId:$option}}) { projectV2Item { id } } }' \ + -f project="$PROJECT_ID" \ + -f item="$item_id" \ + -f field="$STATUS_FIELD_ID" \ + -f option="$STATUS_DONE_OPTION_ID" >/dev/null; then + echo "::warning::Issue #${issue} was closed, but project status update failed." + continue + fi + + echo "Issue #${issue} closed and project status set to Done." + done <<< "${{ steps.refs.outputs.issues }}" diff --git a/.github/workflows/project-pr-status.yml b/.github/workflows/project-pr-status.yml new file mode 100644 index 00000000..04e1f7f9 --- /dev/null +++ b/.github/workflows/project-pr-status.yml @@ -0,0 +1,79 @@ +name: Track PR status in project + +on: + pull_request: + types: [opened, reopened, ready_for_review, converted_to_draft, closed] + +permissions: + contents: read + pull-requests: read + repository-projects: write + +jobs: + update-pr-status: + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.DECODED_GITHUB_TOKEN || github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PROJECT_ID: PVT_kwDOCf1dEc4BUHn- + PROJECT_NUMBER: "3" + STATUS_FIELD_ID: PVTSSF_lADOCf1dEc4BUHn-zhBR8Fk + STATUS_IN_PROGRESS_OPTION_ID: "47fc9ee4" + STATUS_DONE_OPTION_ID: "98236657" + steps: + - name: Set PR project status + shell: bash + run: | + set -euo pipefail + owner="${REPO%%/*}" + repo="${REPO#*/}" + + if [ "${{ github.event.action }}" = "closed" ]; then + status_option="$STATUS_DONE_OPTION_ID" + status_name="Done" + else + status_option="$STATUS_IN_PROGRESS_OPTION_ID" + status_name="In Progress" + fi + + pr_data=$( + gh api graphql \ + -f query='query($owner:String!, $repo:String!, $number:Int!) { repository(owner:$owner, name:$repo) { pullRequest(number:$number) { id projectItems(first:20) { nodes { id project { ... on ProjectV2 { number } } } } } } }' \ + -f owner="$owner" \ + -f repo="$repo" \ + -F number="$PR_NUMBER" + ) + + pr_id=$(jq -r '.data.repository.pullRequest.id' <<<"$pr_data") + item_id=$( + jq -r ".data.repository.pullRequest.projectItems.nodes[] | select(.project.number == ${PROJECT_NUMBER}) | .id" <<<"$pr_data" | + head -n 1 + ) + + if [ -z "$item_id" ]; then + item_id=$( + gh api graphql \ + -f query='mutation($project:ID!, $content:ID!) { addProjectV2ItemById(input:{projectId:$project, contentId:$content}) { item { id } } }' \ + -f project="$PROJECT_ID" \ + -f content="$pr_id" \ + --jq '.data.addProjectV2ItemById.item.id' + ) || true + fi + + if [ -z "$item_id" ]; then + echo "::warning::PR #${PR_NUMBER} project item was not found or created; skipping status update." + exit 0 + fi + + if ! gh api graphql \ + -f query='mutation($project:ID!, $item:ID!, $field:ID!, $option:String!) { updateProjectV2ItemFieldValue(input:{projectId:$project, itemId:$item, fieldId:$field, value:{singleSelectOptionId:$option}}) { projectV2Item { id } } }' \ + -f project="$PROJECT_ID" \ + -f item="$item_id" \ + -f field="$STATUS_FIELD_ID" \ + -f option="$status_option" >/dev/null; then + echo "::warning::PR #${PR_NUMBER} project status update failed." + exit 0 + fi + + echo "PR #${PR_NUMBER} project status set to ${status_name}." diff --git a/docs/GIT-WORKFLOW.md b/docs/GIT-WORKFLOW.md index 6e21bd4a..2ecc5abb 100644 --- a/docs/GIT-WORKFLOW.md +++ b/docs/GIT-WORKFLOW.md @@ -124,10 +124,12 @@ hotfix/* ──PR──▶ main (긴급 시에만) 1. `dev`에서 작업 브랜치 생성: `git checkout -b feat/-xxx dev` 2. **즉시 Draft PR 생성** — 프로젝트 보드가 자동으로 **In Progress** 전환 3. 작업 완료 → Draft 해제 → 리뷰 요청 -4. 리뷰 통과 후 `dev`에 머지 → 프로젝트 보드 자동 **Done** + 이슈 close +4. 리뷰 통과 후 `dev`에 머지 → `.github/workflows/dev-merge-close-issues.yml` 이 연결 이슈 close + 프로젝트 보드 **Done** 처리 5. 릴리스 준비 시 `dev` → `main` PR 생성 6. CI 체크 통과 + 리뷰 후 `main`에 머지 → Vercel 자동 배포 +완료 기준은 `dev` 머지다. `main`은 GitHub default branch라 `Closes #N` native auto-close 대상이지만, 팀 운영에서는 `dev` 머지 이후 `main` 반영이 필수 흐름이므로 feature/bug/docs 이슈는 `dev` 머지 시 닫는다. + ## 프로젝트 보드 자동 추적 [decoded-monorepo 프로젝트 #3](https://github.com/orgs/decodedcorp/projects/3)의 활성 자동화: @@ -136,13 +138,32 @@ hotfix/* ──PR──▶ main (긴급 시에만) |--------|------| | 신규 이슈/PR 생성 | Todo로 자동 추가 | | **PR이 이슈에 링크됨** (`Closes #N`) | **In Progress** | -| PR 머지 | Done + 이슈 자동 close | +| PR opened/reopened/ready_for_review | `.github/workflows/project-pr-status.yml` 이 PR item을 **In Progress** 보정 | +| PR closed | `.github/workflows/project-pr-status.yml` 이 PR item을 **Done** 보정 | +| `dev` 대상 PR 머지 | `.github/workflows/dev-merge-close-issues.yml` 이 `Closes #N` 연결 이슈 close + Done 보정 | ### 중요 - **브랜치 생성만으로는 전환 안 됨** — Draft PR 필요 - 브랜치 이름에 이슈 번호 포함 권장: `feat/27-follow-api` - 리뷰 전이라도 Draft PR을 먼저 올려 진행 가시화 +- `decoded`의 default branch는 `main`이므로 GitHub native `Closes #N`만으로는 `dev` 머지 시 이슈가 자동 close되지 않는다. `dev` 머지 close는 `.github/workflows/dev-merge-close-issues.yml` 이 담당한다. +- Project v2 상태 보정까지 동작하려면 repository secret `DECODED_GITHUB_TOKEN`이 필요하다. 이 토큰은 `decoded` issue/PR 읽기, issue close, org Project #3 item/status 쓰기 권한을 가져야 한다. secret이 없으면 workflow는 `GITHUB_TOKEN`으로 시도하지만 org Project 업데이트는 실패할 수 있다. +- GitHub Actions workflow 활성화 기준은 default branch(`main`) 반영이다. 이 변경은 팀 흐름대로 `dev`에 먼저 머지하되, 자동화가 실제 기준선으로 안정 동작하는 시점은 `dev` 이후 `main`까지 반영된 뒤다. +- 자동화 도입 이전에 `dev`로 머지된 PR은 수동 백필 대상이다. `Closes/Fixes/Resolves #N`가 있는 merged PR을 감사해 열린 이슈가 남아 있으면 PR 번호를 남기고 close한다. + +### Dev merge close 감사 + +정기적으로 다음 조건을 확인한다. + +1. 대상: `base=dev`, `state=merged` PR +2. PR 본문에 `Closes #N`, `Fixes #N`, `Resolves #N`가 있음 +3. 연결 이슈가 아직 `OPEN` +4. 실제 완료 기준이 `dev` 머지라면 이슈에 머지 PR 번호를 코멘트하고 close + +`refs #N`는 단순 참조이므로 close 대상이 아니다. + +2026-05-21 백필 결과: 자동화 도입 전 누락된 15개 이슈를 닫았고, 재검사에서 closing reference가 남긴 열린 이슈는 0건이다. `refs #518`는 문서 마이그레이션 장기 트래킹이라 열린 상태로 유지한다. ### 숏컷: `scripts/start-issue.sh`