From 6c9e20558c7a45a76d73a1e043a2fa612653b64b Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 14 May 2026 20:38:43 +0900 Subject: [PATCH 1/3] docs: add content-studio pipeline v2 design spec (#530) Post picker modal, UUID packet ID fix, unified LLM call, channel thumbnails, and Research/Firecrawl removal. Co-Authored-By: Claude Opus 4.6 --- ...05-14-content-studio-pipeline-v2-design.md | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-14-content-studio-pipeline-v2-design.md diff --git a/docs/superpowers/specs/2026-05-14-content-studio-pipeline-v2-design.md b/docs/superpowers/specs/2026-05-14-content-studio-pipeline-v2-design.md new file mode 100644 index 00000000..d3c7b93d --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-content-studio-pipeline-v2-design.md @@ -0,0 +1,254 @@ +# Content Studio Pipeline v2 Design + +Issue: #530 + +## Summary + +Content Studio 파이프라인을 개선하여 post ID 수동 입력 대신 **검색/인기순 선택 → 키워드 추출 → 마케팅 프롬프트 생성 → 3종 채널별 썸네일 자동 생성**까지 하나의 플로우로 완성한다. 동시에 Research/Firecrawl 외부 의존성을 전체 제거한다. + +## Motivation + +- 현재 post ID를 직접 입력하는 UX가 불편하고 오류 유발 (500 에러 포함) +- `packet_${postId}` prefix가 UUID 컬럼에 들어가면서 `invalid input syntax for type uuid` 에러 발생 +- Firecrawl 기반 Research는 post 내부 데이터로 충분히 대체 가능 — 불필요한 외부 의존성 +- 마케팅 콘텐츠(YouTube/Instagram/X) 제작 썸네일 자동화 부재 + +## Approach + +**순차 스텝 파이프라인 (Approach A)**. 각 스텝이 명확히 분리되어 스텝별 에러 복구가 간단하며, `gpt-image-2` 이미지 생성 비용을 어드민이 컨트롤할 수 있다. 기존 `page.tsx`의 순차 플로우와 일관성 유지. + +## Pipeline Flow + +``` +[PostPickerModal] Meilisearch 검색 + 인기순 리스트 → Post 선택 + ↓ +[Packet 생성] crypto.randomUUID() + post 데이터 수집 + ↓ +[통합 LLM 호출] 규칙 키워드 + structured output + → { keywords, imagePrompts (YT/IG feed/IG story), variants (YT/IG/X) } + ↓ +[어드민 프리뷰] 키워드 + 프롬프트 + variant 텍스트 확인/수정 + ↓ +[썸네일 생성] "Generate Thumbnails" 버튼 → Promise.all 4종 병렬 + → gpt-image-2 edit 모드 (post 원본 이미지 reference) + ↓ +[저장] Supabase Storage (content-studio/{packetId}/) + ↓ +[Governance] celebrity risk, disclosure 등 체크 (research 규칙 제외) + ↓ +[어드민 리뷰] 썸네일 + 텍스트 확인/수정 → approve/reject +``` + +## Section 1: Post 선택 Modal + +### Component + +`PostPickerModal` — 새 컴포넌트, `app/admin/content-studio/` 하위에 생성. + +### 구조 + +- 상단: Meilisearch 검색 input (debounce 300ms, 기존 한글 검색 인프라 활용) +- 검색 미입력 시: Supabase에서 `view_count + like_count` 기준 인기순 상위 20개 표시 +- 검색 입력 시: Meilisearch `posts` 인덱스 검색 결과 표시 +- 각 행: 썸네일(40×40) + post title + artist/group name + view_count + like_count +- 행 클릭 시: Modal 닫힘 → 선택된 postId로 Packet 생성 자동 트리거 + +### 데이터 흐름 + +- 인기순 초기 리스트: Supabase 직접 쿼리 (`posts` 테이블, `view_count + like_count` 내림차순, limit 20) +- 검색: 기존 Meilisearch 클라이언트 재사용 + +### 기존 코드 변경 + +- `page.tsx`: postId 텍스트 입력 폼 → "Select Post" 버튼 + PostPickerModal로 교체 +- 수동 ID 입력은 Modal 내 하단에 "Enter ID manually" 링크로 유지 (fallback) + +## Section 2: Packet ID 수정 + +### 버그 수정 + +`packet-builder.ts`: + +- `packetId()`: `packet_${postId}` → `crypto.randomUUID()` +- `variantId()`: `${packetIdValue}_${format}` → `crypto.randomUUID()` +- `post_id` FK가 이미 `content_packets` 테이블에서 unique constraint이므로 중복 방지는 DB가 담당 + +### API + +변경 없음. `POST /api/v1/content/packets { postId }` 그대로 유지. 내부에서 UUID 생성만 변경. + +### DB 호환성 + +- `db.ts`의 `upsertPacket`: `onConflict: "post_id"` 유지 — 같은 post로 재생성하면 기존 packet 업데이트 +- 기존 DB 데이터: `packet_${uuid}` 형식의 기존 레코드는 그대로 두고, 신규만 순수 UUID + +## Section 3: 통합 LLM 호출 + +### 새 스키마 + +`llm-schemas.ts`에 `unifiedContentResponseSchema` 추가: + +```typescript +{ + keywords: string[], + imagePrompts: { + youtube: string, + instagram_feed: string, + instagram_story: string, + }, + variants: { + youtube: { title: string, body: string, hashtags: string[] }, + instagram: { title: string, body: string, hashtags: string[] }, + x: { title: string, body: string, hashtags: string[] }, + } +} +``` + +### 호출 플로우 + +1. `packet-builder.ts`에서 규칙 기반 키워드 추출: brand, artist, group, solution categories를 post 데이터에서 수집 +2. 새 함수 `generateUnifiedContent(packet, ruleKeywords)` — 단일 OpenAI structured output 호출 +3. 입력: packet 전체 데이터 + 규칙 키워드 배열 +4. 출력: 위 스키마. LLM이 규칙 키워드를 포함하되 추가 마케팅 키워드도 생성 + +### 기존 코드 정리 + +- `llm-generation.ts`의 `generateVariantsWithMode()` → 새 `generateUnifiedContent()`로 교체 +- `generateChannelVariants()` (템플릿 기반) → template fallback으로 유지 +- `llm-prompts.ts` → 새 통합 프롬프트로 교체 +- 기존 `contentVariantLLMResponseSchema` → 새 `unifiedContentResponseSchema`로 교체 + +### Template fallback + +- `CONTENT_STUDIO_LLM_ENABLED=false`이면 기존 `generateChannelVariants()` + 빈 keywords/imagePrompts 반환 +- LLM 호출 실패 시에도 동일하게 fallback + +## Section 4: 3종 채널별 썸네일 생성 + +### 채널별 사이즈 + +| 채널 | 포맷 키 | 사이즈 | 용도 | +| --------------- | ----------------- | --------- | ----------- | +| YouTube | `youtube` | 1536×1024 | 썸네일 | +| X (Twitter) | `x` | 1536×1024 | 카드 이미지 | +| Instagram Feed | `instagram_feed` | 1024×1024 | 피드 정사각 | +| Instagram Story | `instagram_story` | 1024×1536 | 스토리 세로 | + +### 생성 플로우 + +1. 통합 LLM 결과에서 채널별 `imagePrompts` 추출 +2. post 원본 이미지 URL을 reference image로 사용 +3. `gpt-image-2` edit 모드로 4종 `Promise.all` 병렬 호출 +4. edit 실패 시 → 순수 generate fallback (reference 없이) +5. 생성된 이미지 → Supabase Storage `content-studio/{packetId}/` 경로에 업로드 +6. 업로드 실패 시 → data URL (base64) fallback + +### 기존 코드 활용 + +- `openai-client.ts`의 `fetchImageBytesFromOpenAIEdit` + `uploadToStorage` 재사용 +- 이미지 모델 기본값: `gpt-image-1` → `gpt-image-2`로 업데이트 (`CONTENT_STUDIO_IMAGE_MODEL` 환경변수) +- 새 함수 `generateChannelThumbnails(packet, imagePrompts)` 추가 + +### API + +새 엔드포인트: `POST /api/v1/content/packets/{id}/generate-thumbnails` + +- 요청: `{ imagePrompts: { youtube, instagram_feed, instagram_story } }` +- 응답: `{ thumbnails: { youtube: url, x: url, instagram_feed: url, instagram_story: url }, failures: [] }` +- YouTube와 X는 동일 사이즈(1536×1024)이므로 imagePrompt는 3개이나 출력은 4개 (X는 youtube prompt 재사용) + +### UI + +- LLM 생성 결과 아래에 "Generate Thumbnails" 버튼 추가 +- 이미지 프롬프트를 어드민이 수정할 수 있는 textarea 3개 (YT, IG Feed, IG Story) +- 생성 완료 후 4종 썸네일 프리뷰 그리드 (2×2) + +## Section 5: Research/Firecrawl 전체 제거 + +### 삭제 대상 파일 (13개) + +| 경로 | 유형 | +| ----------------------------------------------------------- | ------------- | +| `lib/content-studio/research/index.ts` | 배럴 | +| `lib/content-studio/research/firecrawl-client.ts` | Firecrawl API | +| `lib/content-studio/research/domain-policy.ts` | 도메인 정책 | +| `lib/content-studio/research/normalization.ts` | 정규화 | +| `lib/content-studio/research/query-suggestions.ts` | 쿼리 제안 | +| `lib/content-studio/research/recommendations.ts` | 추천 | +| `lib/content-studio/research/service.ts` | 서비스 | +| `lib/content-studio/__tests__/research.test.ts` | 테스트 | +| `app/admin/content-studio/ResearchPanel.tsx` | UI 패널 | +| `app/admin/content-studio/__tests__/ResearchPanel.test.tsx` | UI 테스트 | +| `app/api/v1/content/research/route.ts` | API 라우트 | +| `app/api/v1/content/research/__tests__/route.test.ts` | API 테스트 | +| `research/` 디렉토리 전체 | — | + +### 스키마 정리 (`schemas.ts`) + +삭제 대상: + +- `researchSourceSchema`, `researchInsightSchema`, `researchRecommendationsSchema`, `researchRunSchema`, `runResearchRequestSchema` + 관련 타입 export +- `contentPacketSchema`의 `externalEvidence` optional 필드 +- `contentVariantSchema`의 `researchProvenance`, `claims`, `missingFacts` optional 필드 +- `generateVariantsRequestSchema`의 `researchContext`, `useResearchInCopy` 필드 +- `assetPlanRequestSchema`, `shortFormPlanRequestSchema`의 `researchRun`, `useResearchInCopy` 필드 + +### 코드 참조 정리 + +- `page.tsx`: ResearchPanel import + 관련 state 제거 +- `llm-generation.ts`: `researchContext` 관련 로직 제거 +- `openai-client.ts`: research 파라미터 제거 +- `governance-check.ts`: research 관련 규칙만 제거 + +### Governance 유지 항목 + +- celebrity risk check (artist/group name 기반) +- disclosure flags (AI generated, synthetic media, sponsored, rights risk) +- LLM governance check (`contentGovernanceLLMSchema`) + +## Environment Variables + +| 변수 | 용도 | 기본값 | +| ---------------------------- | ---------------- | ------------- | +| `OPENAI_API_KEY` | OpenAI API 키 | — (필수) | +| `CONTENT_STUDIO_LLM_ENABLED` | LLM 호출 활성화 | `false` | +| `CONTENT_STUDIO_MODEL` | 텍스트 LLM 모델 | `gpt-4.1` | +| `CONTENT_STUDIO_IMAGE_MODEL` | 이미지 생성 모델 | `gpt-image-2` | + +## Out of Scope + +- Grok AI 비디오 파이프라인 +- ai-server ARQ 마이그레이션 + R2 저장소 전환 +- 원클릭 자동 파이프라인 모드 (v2 안정화 후 별도 이슈) + +## File Change Summary + +### 새 파일 + +- `app/admin/content-studio/PostPickerModal.tsx` +- `app/api/v1/content/packets/[id]/generate-thumbnails/route.ts` + +### 수정 파일 + +- `lib/content-studio/packet-builder.ts` — UUID 생성 + 규칙 키워드 추출 +- `lib/content-studio/llm-schemas.ts` — `unifiedContentResponseSchema` 추가, 기존 research 스키마 정리 +- `lib/content-studio/llm-generation.ts` — `generateUnifiedContent()` 교체 +- `lib/content-studio/llm-prompts.ts` — 통합 프롬프트 교체 +- `lib/content-studio/schemas.ts` — research 필드 삭제 +- `lib/content-studio/assets/openai-client.ts` — `generateChannelThumbnails()` 추가, 기본 모델 변경 +- `lib/content-studio/index.ts` — export 정리 +- `app/admin/content-studio/page.tsx` — PostPickerModal 통합, ResearchPanel 제거, 썸네일 UI 추가 +- `app/admin/content-studio/AssetPanel.tsx` — research 참조 제거 +- `app/admin/content-studio/ShortFormPanel.tsx` — research 참조 제거 +- `lib/content-studio/governance-check.ts` — research 규칙 제거 +- `lib/content-studio/__tests__/llm-generation.test.ts` — research 컨텍스트 테스트 정리 +- `lib/content-studio/__tests__/content-studio.test.ts` — 통합 테스트 업데이트 + +### 삭제 파일 + +- `lib/content-studio/research/` 디렉토리 전체 (7개) +- `lib/content-studio/__tests__/research.test.ts` +- `app/admin/content-studio/ResearchPanel.tsx` +- `app/admin/content-studio/__tests__/ResearchPanel.test.tsx` +- `app/api/v1/content/research/route.ts` +- `app/api/v1/content/research/__tests__/route.test.ts` From 1c4e79b290c6818dcaf3c0f999d2e07f62cd8155 Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 21 May 2026 14:47:10 +0900 Subject: [PATCH 2/3] ci(github): close linked issues on dev merge --- .github/workflows/dev-merge-close-issues.yml | 97 ++++++++++++++++++++ docs/GIT-WORKFLOW.md | 7 +- 2 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/dev-merge-close-issues.yml diff --git a/.github/workflows/dev-merge-close-issues.yml b/.github/workflows/dev-merge-close-issues.yml new file mode 100644 index 00000000..90e6fa7d --- /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: ${{ 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/docs/GIT-WORKFLOW.md b/docs/GIT-WORKFLOW.md index 6e21bd4a..5d5a945e 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,14 @@ hotfix/* ──PR──▶ main (긴급 시에만) |--------|------| | 신규 이슈/PR 생성 | Todo로 자동 추가 | | **PR이 이슈에 링크됨** (`Closes #N`) | **In Progress** | -| PR 머지 | Done + 이슈 자동 close | +| `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` 이 담당한다. ### 숏컷: `scripts/start-issue.sh` From d2c588648c646568c444b62974a4cb07cfcdece8 Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 21 May 2026 15:42:53 +0900 Subject: [PATCH 3/3] ci(github): sync issue and project status automation --- .github/workflows/dev-merge-close-issues.yml | 2 +- .github/workflows/project-pr-status.yml | 79 ++++++++++++++++++++ docs/GIT-WORKFLOW.md | 18 +++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/project-pr-status.yml diff --git a/.github/workflows/dev-merge-close-issues.yml b/.github/workflows/dev-merge-close-issues.yml index 90e6fa7d..689a95c9 100644 --- a/.github/workflows/dev-merge-close-issues.yml +++ b/.github/workflows/dev-merge-close-issues.yml @@ -15,7 +15,7 @@ jobs: if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev' runs-on: ubuntu-latest env: - GH_TOKEN: ${{ github.token }} + 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 }} 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 5d5a945e..2ecc5abb 100644 --- a/docs/GIT-WORKFLOW.md +++ b/docs/GIT-WORKFLOW.md @@ -138,6 +138,8 @@ hotfix/* ──PR──▶ main (긴급 시에만) |--------|------| | 신규 이슈/PR 생성 | Todo로 자동 추가 | | **PR이 이슈에 링크됨** (`Closes #N`) | **In Progress** | +| 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 보정 | ### 중요 @@ -146,6 +148,22 @@ hotfix/* ──PR──▶ main (긴급 시에만) - 브랜치 이름에 이슈 번호 포함 권장: `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`