diff --git a/.github/scripts/assign-reviewer.js b/.github/scripts/assign-reviewer.js deleted file mode 100644 index e534f15..0000000 --- a/.github/scripts/assign-reviewer.js +++ /dev/null @@ -1,179 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const { sbFetch } = require('./supabase'); - -const DEFAULT_SETTING = { deadline_hours: 96, exclude_label: 'No Review' }; - -/** review_setting 단일 행 조회 — 없거나 실패하면 기본값(4일, 'No Review') */ -async function loadSetting(core) { - try { - const rows = await sbFetch( - 'review_setting?id=eq.1&select=deadline_hours,exclude_label', - ); - if (rows.length > 0) return rows[0]; - } catch (error) { - core.warning(`review_setting 조회 실패, 기본값 사용: ${error.message}`); - } - return DEFAULT_SETTING; -} - -/** 96 → "4일", 30 → "30시간" */ -function formatDeadline(hours) { - return hours % 24 === 0 ? `${hours / 24}일` : `${hours}시간`; -} - -/** 배정 제외 라벨이 붙어 있는지 */ -function hasExcludeLabel(pr, excludeLabel) { - return (pr.labels ?? []).some((label) => label.name === excludeLabel); -} - -/** - * round-robin 후보 선정: 휴가 아님 + PR 작성자 제외 중 - * last_assigned_at이 가장 오래된 사람(null 최우선), 동률이면 rotation_order 낮은 순. - */ -function pickCandidate(reviewers, prAuthor) { - const candidates = reviewers.filter( - (reviewer) => !reviewer.is_on_vacation && reviewer.github_username !== prAuthor, - ); - if (candidates.length === 0) { - return null; - } - - return [...candidates].sort((a, b) => { - if (a.last_assigned_at === null && b.last_assigned_at !== null) return -1; - if (a.last_assigned_at !== null && b.last_assigned_at === null) return 1; - if (a.last_assigned_at !== b.last_assigned_at) { - return a.last_assigned_at < b.last_assigned_at ? -1 : 1; - } - return a.rotation_order - b.rotation_order; - })[0]; -} - -function loadNotifyIds() { - const filePath = path.join(__dirname, '..', 'notify_ids.json'); - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} - -/** - * TARGET_REPO(2025-bom-bom)의 열린 PR을 폴링해서 미배정 PR에 리뷰어를 배정한다. - * - 이미 OPEN 배정 기록이 있는 PR 스킵 - * - 수동으로 리뷰어가 지정됐거나 이미 리뷰가 달린 PR 스킵 (수동 운영 존중) - */ -async function run({ github, core }) { - const [owner, repo] = process.env.TARGET_REPO.split('/'); - const baseBranches = process.env.TARGET_BASE_BRANCHES.split(',').map((s) => - s.trim(), - ); - - const setting = await loadSetting(core); - const reviewers = await sbFetch('reviewer?select=*'); - const openAssignments = await sbFetch( - 'review_assignment?status=eq.OPEN&select=pr_number', - ); - const assignedPrNumbers = new Set(openAssignments.map((a) => a.pr_number)); - - const prs = await github.paginate(github.rest.pulls.list, { - owner, - repo, - state: 'open', - per_page: 100, - }); - - const notifyIds = loadNotifyIds(); - const assigned = []; - - for (const pr of prs) { - if (pr.draft) continue; - if (!baseBranches.includes(pr.base.ref)) continue; - if (hasExcludeLabel(pr, setting.exclude_label)) continue; - if (assignedPrNumbers.has(pr.number)) continue; - if (pr.requested_reviewers && pr.requested_reviewers.length > 0) continue; - - const { data: reviews } = await github.rest.pulls.listReviews({ - owner, - repo, - pull_number: pr.number, - }); - if (reviews.length > 0) continue; - - const candidate = pickCandidate(reviewers, pr.user.login); - if (!candidate) { - core.warning(`PR #${pr.number}: 배정 가능한 리뷰어가 없습니다 (전원 휴가 등)`); - continue; - } - - // GitHub API 지정을 DB 기록보다 먼저 수행 — 실패 시 유령 배정 방지 - await github.rest.pulls.requestReviewers({ - owner, - repo, - pull_number: pr.number, - reviewers: [candidate.github_username], - }); - - const now = new Date(); - const deadline = new Date( - now.getTime() + setting.deadline_hours * 60 * 60 * 1000, - ); - - await sbFetch('review_assignment', { - method: 'POST', - body: { - reviewer_id: candidate.id, - pr_number: pr.number, - pr_title: pr.title, - pr_author: pr.user.login, - pr_url: pr.html_url, - assigned_at: now.toISOString(), - deadline_at: deadline.toISOString(), - status: 'OPEN', - }, - }); - await sbFetch(`reviewer?id=eq.${candidate.id}`, { - method: 'PATCH', - body: { last_assigned_at: now.toISOString(), updated_at: now.toISOString() }, - }); - - // 같은 실행 안에서 여러 PR을 배정할 때도 로테이션이 이어지도록 로컬 상태 갱신 - candidate.last_assigned_at = now.toISOString(); - - assigned.push({ - prNumber: pr.number, - title: pr.title, - reviewer: candidate, - }); - core.notice(`PR #${pr.number} 리뷰어 배정: ${candidate.github_username}`); - } - - if (assigned.length === 0) { - core.setOutput('assigned', 'false'); - core.notice('새로 배정할 PR이 없습니다'); - return; - } - - const mentions = [ - ...new Set( - assigned.map((a) => { - const discordId = notifyIds[a.reviewer.github_username]; - return discordId ? `<@${discordId}>` : a.reviewer.github_username; - }), - ), - ].join(' '); - const fields = assigned.map((a) => ({ - name: `#${a.prNumber} ${a.title}`, - value: `리뷰어: ${a.reviewer.display_name} · 기한 ${formatDeadline(setting.deadline_hours)}`, - })); - - core.setOutput('assigned', 'true'); - core.setOutput('mentions', mentions); - core.setOutput('fields', JSON.stringify(fields)); -} - -module.exports = { - pickCandidate, - run, - formatDeadline, - hasExcludeLabel, - DEFAULT_SETTING, -}; diff --git a/.github/scripts/assign-reviewer.test.js b/.github/scripts/assign-reviewer.test.js deleted file mode 100644 index 2307573..0000000 --- a/.github/scripts/assign-reviewer.test.js +++ /dev/null @@ -1,122 +0,0 @@ -'use strict'; - -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const { pickCandidate } = require('./assign-reviewer'); -const { buildNotification } = require('./check-overdue'); - -const reviewer = (overrides) => ({ - id: 1, - github_username: 'user1', - display_name: '유저1', - rotation_order: 1, - is_on_vacation: false, - last_assigned_at: null, - ...overrides, -}); - -test('pickCandidate: 휴가 중인 리뷰어는 제외한다', () => { - const reviewers = [ - reviewer({ id: 1, github_username: 'a', is_on_vacation: true }), - reviewer({ id: 2, github_username: 'b' }), - ]; - assert.equal(pickCandidate(reviewers, 'author').github_username, 'b'); -}); - -test('pickCandidate: PR 작성자는 제외한다', () => { - const reviewers = [ - reviewer({ id: 1, github_username: 'a' }), - reviewer({ id: 2, github_username: 'b', last_assigned_at: '2026-01-01T00:00:00Z' }), - ]; - assert.equal(pickCandidate(reviewers, 'a').github_username, 'b'); -}); - -test('pickCandidate: last_assigned_at이 null인 리뷰어가 최우선이다', () => { - const reviewers = [ - reviewer({ id: 1, github_username: 'a', last_assigned_at: '2026-01-01T00:00:00Z' }), - reviewer({ id: 2, github_username: 'b', last_assigned_at: null, rotation_order: 9 }), - ]; - assert.equal(pickCandidate(reviewers, 'author').github_username, 'b'); -}); - -test('pickCandidate: 가장 오래 전에 배정받은 리뷰어를 선택한다', () => { - const reviewers = [ - reviewer({ id: 1, github_username: 'a', last_assigned_at: '2026-06-01T00:00:00Z' }), - reviewer({ id: 2, github_username: 'b', last_assigned_at: '2026-05-01T00:00:00Z' }), - reviewer({ id: 3, github_username: 'c', last_assigned_at: '2026-07-01T00:00:00Z' }), - ]; - assert.equal(pickCandidate(reviewers, 'author').github_username, 'b'); -}); - -test('pickCandidate: 동률이면 rotation_order 낮은 순이다', () => { - const sameTime = '2026-06-01T00:00:00Z'; - const reviewers = [ - reviewer({ id: 1, github_username: 'a', last_assigned_at: sameTime, rotation_order: 3 }), - reviewer({ id: 2, github_username: 'b', last_assigned_at: sameTime, rotation_order: 1 }), - ]; - assert.equal(pickCandidate(reviewers, 'author').github_username, 'b'); -}); - -test('pickCandidate: 후보가 없으면 null을 반환한다', () => { - const reviewers = [ - reviewer({ id: 1, github_username: 'a', is_on_vacation: true }), - reviewer({ id: 2, github_username: 'author' }), - ]; - assert.equal(pickCandidate(reviewers, 'author'), null); -}); - -test('buildNotification: 멘션과 초과 시간 필드를 생성한다', () => { - const now = new Date('2026-07-03T12:00:00Z'); - const assignments = [ - { - id: 1, - pr_number: 10, - pr_title: '기능 추가', - deadline_at: '2026-07-03T09:00:00Z', - reviewer: { github_username: 'a', display_name: '유저A' }, - }, - ]; - const { mentions, fields } = buildNotification(assignments, { a: '111' }, now); - assert.equal(mentions, '<@111>'); - assert.deepEqual(fields, [{ name: '#10 기능 추가', value: '담당: 유저A · 3시간 초과' }]); -}); - -test('buildNotification: 매핑 없는 유저는 username으로, 중복 멘션은 제거한다', () => { - const now = new Date('2026-07-03T12:00:00Z'); - const assignments = [ - { - id: 1, - pr_number: 10, - pr_title: 'A', - deadline_at: '2026-07-03T11:00:00Z', - reviewer: { github_username: 'x', display_name: 'X' }, - }, - { - id: 2, - pr_number: 11, - pr_title: 'B', - deadline_at: '2026-07-03T10:00:00Z', - reviewer: { github_username: 'x', display_name: 'X' }, - }, - ]; - const { mentions, fields } = buildNotification(assignments, {}, now); - assert.equal(mentions, 'x'); - assert.equal(fields.length, 2); -}); - -test('formatDeadline: 24의 배수는 일 단위, 아니면 시간 단위로 표기한다', () => { - const { formatDeadline } = require('./assign-reviewer'); - assert.equal(formatDeadline(96), '4일'); - assert.equal(formatDeadline(24), '1일'); - assert.equal(formatDeadline(30), '30시간'); -}); - -test('hasExcludeLabel: 제외 라벨이 있으면 true, 없거나 labels 미존재 시 false', () => { - const { hasExcludeLabel } = require('./assign-reviewer'); - assert.equal( - hasExcludeLabel({ labels: [{ name: 'No Review' }, { name: 'bug' }] }, 'No Review'), - true, - ); - assert.equal(hasExcludeLabel({ labels: [{ name: 'bug' }] }, 'No Review'), false); - assert.equal(hasExcludeLabel({}, 'No Review'), false); -}); diff --git a/.github/scripts/check-overdue.test.js b/.github/scripts/check-overdue.test.js new file mode 100644 index 0000000..056c1ff --- /dev/null +++ b/.github/scripts/check-overdue.test.js @@ -0,0 +1,46 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { buildNotification } = require('./check-overdue'); + +test('buildNotification: 멘션과 초과 시간 필드를 생성한다', () => { + const now = new Date('2026-07-03T12:00:00Z'); + const assignments = [ + { + id: 1, + pr_number: 10, + pr_title: '기능 추가', + deadline_at: '2026-07-03T09:00:00Z', + reviewer: { github_username: 'a', display_name: '유저A' }, + }, + ]; + const { mentions, fields } = buildNotification(assignments, { a: '111' }, now); + assert.equal(mentions, '<@111>'); + assert.deepEqual(fields, [ + { name: '#10 기능 추가', value: '담당: 유저A · 3시간 초과' }, + ]); +}); + +test('buildNotification: 매핑 없는 유저는 username으로, 중복 멘션은 제거한다', () => { + const now = new Date('2026-07-03T12:00:00Z'); + const assignments = [ + { + id: 1, + pr_number: 10, + pr_title: 'A', + deadline_at: '2026-07-03T11:00:00Z', + reviewer: { github_username: 'x', display_name: 'X' }, + }, + { + id: 2, + pr_number: 11, + pr_title: 'B', + deadline_at: '2026-07-03T10:00:00Z', + reviewer: { github_username: 'x', display_name: 'X' }, + }, + ]; + const { mentions, fields } = buildNotification(assignments, {}, now); + assert.equal(mentions, 'x'); + assert.equal(fields.length, 2); +}); diff --git a/.github/scripts/sync-completed.js b/.github/scripts/sync-completed.js deleted file mode 100644 index fddd07d..0000000 --- a/.github/scripts/sync-completed.js +++ /dev/null @@ -1,61 +0,0 @@ -'use strict'; - -const { sbFetch } = require('./supabase'); - -/** - * OPEN 배정들의 PR 상태를 TARGET_REPO에서 확인해 완료 처리한다. - * - PR이 닫혔으면(머지 포함) 무조건 CLOSED — orphan 방지 - * - 배정된 리뷰어가 배정 이후 리뷰를 제출했으면 CLOSED + completed_at 기록 - */ -async function run({ github, core }) { - const [owner, repo] = process.env.TARGET_REPO.split('/'); - - const assignments = await sbFetch( - 'review_assignment?status=eq.OPEN&select=*,reviewer(github_username)', - ); - if (assignments.length === 0) { - core.notice('진행 중인 배정이 없습니다'); - return; - } - - for (const assignment of assignments) { - const { data: pr } = await github.rest.pulls.get({ - owner, - repo, - pull_number: assignment.pr_number, - }); - - if (pr.state === 'closed') { - await sbFetch(`review_assignment?id=eq.${assignment.id}`, { - method: 'PATCH', - body: { - status: 'CLOSED', - completed_at: pr.merged_at ?? pr.closed_at ?? new Date().toISOString(), - }, - }); - core.notice(`PR #${assignment.pr_number} close → 배정 종료`); - continue; - } - - const { data: reviews } = await github.rest.pulls.listReviews({ - owner, - repo, - pull_number: assignment.pr_number, - }); - const review = reviews.find( - (r) => - r.user && - r.user.login === assignment.reviewer.github_username && - new Date(r.submitted_at) >= new Date(assignment.assigned_at), - ); - if (review) { - await sbFetch(`review_assignment?id=eq.${assignment.id}`, { - method: 'PATCH', - body: { status: 'CLOSED', completed_at: review.submitted_at }, - }); - core.notice(`PR #${assignment.pr_number} 리뷰 제출 → 배정 완료`); - } - } -} - -module.exports = { run }; diff --git a/.github/workflows/review-deadline-check.yml b/.github/workflows/review-deadline-check.yml new file mode 100644 index 0000000..443b9fd --- /dev/null +++ b/.github/workflows/review-deadline-check.yml @@ -0,0 +1,54 @@ +name: Review Deadline Check + +# 리뷰 기한을 초과한 배정을 찾아 Discord로 알립니다. +# 배정/완료 처리는 woowacourse-teams/2025-bom-bom의 워크플로우가 담당하고, +# 이 워크플로우는 Supabase와 Discord만 사용합니다 (GitHub API 접근 없음). +# 주의: schedule은 default 브랜치(main)의 워크플로우만 실행됩니다. +on: + schedule: + - cron: '0 * * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Find overdue assignments + id: check + uses: actions/github-script@v7 + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + with: + script: | + const { run } = require('./.github/scripts/check-overdue.js'); + await run({ core }); + + - name: Send Discord notification + if: steps.check.outputs.has_overdue == 'true' + uses: ./.github/actions/discord-notify + with: + webhook: ${{ secrets.DISCORD_REVIEW_WEBHOOK_URL }} + content: '⏰ *리뷰 기한이 초과된 PR이 있습니다!*' + mentions: ${{ steps.check.outputs.mentions }} + title: '기한 초과 리뷰 목록' + url: https://github.com/woowacourse-teams/2025-bom-bom/pulls + color: 15158332 + fields: ${{ steps.check.outputs.fields }} + + - name: Mark as notified + if: steps.check.outputs.has_overdue == 'true' + uses: actions/github-script@v7 + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + with: + script: | + const { markNotified } = require('./.github/scripts/check-overdue.js'); + await markNotified({ core }, '${{ steps.check.outputs.ids }}'); diff --git a/.github/workflows/reviewer-rotation.yml b/.github/workflows/reviewer-rotation.yml deleted file mode 100644 index cd62bbf..0000000 --- a/.github/workflows/reviewer-rotation.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: Reviewer Rotation - -# woowacourse-teams/2025-bom-bom의 열린 PR을 폴링해서 -# 리뷰 완료 동기화 → 신규 배정 → 기한 초과 알림을 순서대로 수행합니다. -# 주의: schedule은 default 브랜치(main)의 워크플로우만 실행됩니다. -on: - schedule: - - cron: '*/10 * * * *' - workflow_dispatch: - -permissions: - contents: read - -env: - TARGET_REPO: woowacourse-teams/2025-bom-bom - TARGET_BASE_BRANCHES: server-dev,email-server-dev - -jobs: - rotate: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Sync completed reviews - uses: actions/github-script@v7 - env: - SUPABASE_URL: ${{ secrets.SUPABASE_URL }} - SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} - with: - github-token: ${{ secrets.BOMBOM_REPO_TOKEN }} - script: | - const { run } = require('./.github/scripts/sync-completed.js'); - await run({ github, core }); - - - name: Assign reviewers - id: assign - uses: actions/github-script@v7 - env: - SUPABASE_URL: ${{ secrets.SUPABASE_URL }} - SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} - with: - github-token: ${{ secrets.BOMBOM_REPO_TOKEN }} - script: | - const { run } = require('./.github/scripts/assign-reviewer.js'); - await run({ github, core }); - - - name: Send Discord notification (assigned) - if: steps.assign.outputs.assigned == 'true' - uses: ./.github/actions/discord-notify - with: - webhook: ${{ secrets.DISCORD_REVIEW_WEBHOOK_URL }} - content: '👀 *리뷰어가 배정되었습니다!*' - mentions: ${{ steps.assign.outputs.mentions }} - title: '새 리뷰 배정' - url: https://github.com/woowacourse-teams/2025-bom-bom/pulls - color: 3447003 - fields: ${{ steps.assign.outputs.fields }} - - - name: Find overdue assignments - id: check - uses: actions/github-script@v7 - env: - SUPABASE_URL: ${{ secrets.SUPABASE_URL }} - SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} - with: - script: | - const { run } = require('./.github/scripts/check-overdue.js'); - await run({ core }); - - - name: Send Discord notification (overdue) - if: steps.check.outputs.has_overdue == 'true' - uses: ./.github/actions/discord-notify - with: - webhook: ${{ secrets.DISCORD_REVIEW_WEBHOOK_URL }} - content: '⏰ *리뷰 기한이 초과된 PR이 있습니다!*' - mentions: ${{ steps.check.outputs.mentions }} - title: '기한 초과 리뷰 목록' - url: https://github.com/woowacourse-teams/2025-bom-bom/pulls - color: 15158332 - fields: ${{ steps.check.outputs.fields }} - - - name: Mark as notified - if: steps.check.outputs.has_overdue == 'true' - uses: actions/github-script@v7 - env: - SUPABASE_URL: ${{ secrets.SUPABASE_URL }} - SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} - with: - script: | - const { markNotified } = require('./.github/scripts/check-overdue.js'); - await markNotified({ core }, '${{ steps.check.outputs.ids }}'); diff --git a/docs/reviewer-assignment/README.md b/docs/reviewer-assignment/README.md index 8d4c577..efb9029 100644 --- a/docs/reviewer-assignment/README.md +++ b/docs/reviewer-assignment/README.md @@ -1,55 +1,56 @@ # 자동 리뷰어 배정 시스템 -`woowacourse-teams/2025-bom-bom` 레포의 백엔드 PR(base: `server-dev`, `email-server-dev`)을 -**이 레포(Bombom-Team/admin)의 GitHub Actions가 10분 간격으로 폴링**해서 리뷰어 1명을 자동 배정하고, -리뷰 기한(기본 4일, 대시보드에서 변경 가능) 초과 시 Discord로 알림을 보냅니다. - -감시 대상 레포(2025-bom-bom)에는 아무 파일도 추가하지 않습니다 — 모든 동작이 이 레포에서 실행됩니다. +`woowacourse-teams/2025-bom-bom`의 백엔드 PR(base: `server-dev`, `email-server-dev`)이 열리면 +**2025-bom-bom 레포의 GitHub Actions가 즉시** 리뷰어 1명을 자동 배정하고, +**이 레포(Bombom-Team/admin)는 리뷰 기한 초과 알림(cron)만** 담당합니다. + +``` +[2025-bom-bom 워크플로우] ← 자동 발급 GITHUB_TOKEN (PAT 불필요) + · PR 열림 → 즉시 배정 + Discord 알림 + · 리뷰 제출 / PR 닫힘 → 배정 완료 처리 +[이 레포 워크플로우] ← GitHub API 접근 없음 + · 매시간 기한 초과 배정 확인 → Discord 알림 +[Supabase + admin 대시보드] + · 리뷰어 명단/순번/휴가/기한 설정/배정 이력 저장 및 운영 +``` ## 구성 요소 | 파일 | 역할 | |---|---| -| `.github/workflows/reviewer-rotation.yml` | 10분 간격 cron: 완료 동기화 → 신규 배정 → 기한 초과 알림 | -| `.github/scripts/assign-reviewer.js` | 미배정 PR 탐색 + round-robin 배정 | -| `.github/scripts/sync-completed.js` | 리뷰 제출/PR close 감지해 배정 완료 처리 | -| `.github/scripts/check-overdue.js` | 기한 초과 배정 Discord 알림 | -| `.github/notify_ids.json` | GitHub username → Discord user ID 매핑 | -| `.github/actions/discord-notify/` | Discord 웹훅 전송 composite action | +| (이 레포) `.github/workflows/review-deadline-check.yml` | 매시간 cron: 기한 초과 배정 Discord 알림 | +| (이 레포) `.github/scripts/check-overdue.js` | 기한 초과 조회 + 알림 메시지 조립 | +| (이 레포) `.github/notify_ids.json` | GitHub username → Discord user ID 매핑 | +| (이 레포) `.github/actions/discord-notify/` | Discord 웹훅 전송 composite action | +| (2025-bom-bom) `.github/workflows/assign-reviewer.yml` | PR 열림 즉시 round-robin 배정 + Discord 알림 | +| (2025-bom-bom) `.github/workflows/review-complete.yml` | 리뷰 제출/PR close 시 배정 완료 처리 | | `docs/reviewer-assignment/schema.sql` | Supabase 테이블 스키마 (콘솔 관리, 근거 문서) | -## 매 실행(10분)마다 하는 일 +## 배정 규칙 (2025-bom-bom 쪽 워크플로우) -1. **완료 동기화**: OPEN 배정들의 PR 상태 확인 — PR이 닫혔거나(머지 포함) 배정된 리뷰어가 리뷰를 제출했으면 `CLOSED + completed_at` 기록 -2. **신규 배정**: 대상 브랜치의 열린 PR 중 ① OPEN 배정 기록 없음 ② 수동 지정된 리뷰어 없음 ③ 아직 리뷰가 없음 ④ 제외 라벨(기본 `No Review`) 없음 인 PR에 대해: - - 휴가 아님 + PR 작성자 제외 중 `last_assigned_at`이 가장 오래된 사람(NULL 최우선, 동률이면 `rotation_order`) 선택 → round-robin - - GitHub 리뷰어 지정 → `review_assignment` INSERT(기한 = `review_setting.deadline_hours`, 기본 96h=4일) → `last_assigned_at` 갱신 → Discord 멘션 알림 -3. **기한 체크**: OPEN & 기한 초과 & 미알림 배정을 Discord로 멘션 (배정당 1회만) +- 대상: `server-dev`, `email-server-dev`로 열린 PR (draft 제외) +- 제외: `No Review` 라벨(설정 가능) / 이미 리뷰어 지정됨 / 기존 OPEN 배정 존재 +- 선택: 휴가 아님 + 작성자 제외 중 `last_assigned_at`이 가장 오래된 사람(NULL 최우선, 동률이면 `rotation_order`) → round-robin +- 기한: `review_setting.deadline_hours` (기본 96h = 4일, admin 대시보드에서 변경) +- 전원 휴가 등 후보 0명이면 PR에 수동 지정 요청 코멘트 ## 필요한 Secrets -| Secret | 값 | -|---|---| -| `SUPABASE_URL` | Supabase 프로젝트 URL | -| `SUPABASE_SERVICE_ROLE_KEY` | Supabase secret key (쓰기 필요, publishable 아님) | -| `DISCORD_REVIEW_WEBHOOK_URL` | 리뷰 알림 Discord 채널 웹훅 | -| `BOMBOM_REPO_TOKEN` | **2025-bom-bom에 쓰기 권한이 있는 PAT** — 리뷰어 지정(requestReviewers)에 필요. Fine-grained라면 `woowacourse-teams/2025-bom-bom`에 Pull requests Read/Write | +**2025-bom-bom** (배정/완료 처리용): `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `DISCORD_REVIEW_WEBHOOK_URL` + +**이 레포** (기한 체크용): `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `DISCORD_REVIEW_WEBHOOK_URL` -등록: `gh secret set -R Bombom-Team/admin` +GitHub 토큰(PAT)은 어느 쪽에도 필요 없습니다 — 리뷰어 지정은 2025-bom-bom 자기 레포 안에서 자동 발급 `GITHUB_TOKEN`으로 수행됩니다. ## 운영 가이드 -- **휴가 설정**: admin 대시보드(`/reviewers`)에서 토글. 휴가 중에는 배정되지 않음 -- **리뷰어 추가/삭제/이름 변경**: admin 대시보드(`/reviewers`)에서 관리. Discord 멘션을 받으려면 `.github/notify_ids.json`에도 ID 추가 필요 -- **기한/제외 라벨 변경**: admin 대시보드 설정에서 수정 (`review_setting` 테이블, 기본: 4일 / `No Review`) -- **특정 PR 자동 배정 제외**: PR에 제외 라벨(기본 `No Review`)을 붙이면 배정하지 않음 -- **수동 운영과의 공존**: 이미 리뷰어가 수동 지정됐거나 리뷰가 달린 PR에는 개입하지 않음 -- **수동 실행**: Actions 탭 → Reviewer Rotation → Run workflow (main 반영 후 가능) +- **휴가 설정 / 리뷰어 추가·삭제·이름 변경 / 기한·제외 라벨 변경**: admin 대시보드(`/reviewers`) +- **특정 PR 자동 배정 제외**: PR에 제외 라벨(기본 `No Review`)을 붙이고 열기 +- **수동 운영과의 공존**: 이미 리뷰어가 지정됐거나 배정 기록이 있는 PR에는 개입하지 않음 - **기한 초과 알림은 배정당 1회만** (`overdue_notified_at` 마커) ## 주의사항 -- **cron/workflow_dispatch는 default 브랜치(main)의 워크플로우 기준으로 동작**합니다. - `server` 머지 후 main에도 반영해야 시스템이 실제로 돌기 시작합니다. -- 배정 지연: cron 특성상 PR 오픈 후 최대 10~15분 안에 배정됩니다 (GitHub cron은 정시 보장이 없음) -- draft PR은 배정하지 않으며, ready_for_review로 전환되면 다음 폴링에서 배정됩니다 +- 이 레포의 cron은 **default 브랜치(main)의 워크플로우 기준**으로 동작합니다. server 머지 후 main 반영 필요 +- 2025-bom-bom의 `email-server-dev` 대상 배정은 해당 브랜치에 워크플로우가 전파된 후부터 동작합니다 +- 리뷰어 명단 변경 시 Discord 멘션을 받으려면 **양쪽 레포의 `notify_ids.json`** 모두 갱신 필요