From 355b7d1f9d6a8aa41acf9ad862f8d00779fb85ee Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 26 Aug 2026 15:16:16 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(root):=20PR=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=EC=96=B4=20=EB=A6=AC=EB=A7=88=EC=9D=B8=EB=93=9C=20Discord=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=9E=90=EB=8F=99=ED=99=94=20(#285)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 24시간 이상 미리뷰 PR을 감지해 평일 KST 10시/15시에 Discord로 리마인드하는 스케줄 워크플로우를 추가했습니다 - 리마인드 전용 채널 웹훅(DISCORD_REMINDER_WEBHOOK_URL)을 사용하도록 했습니다 - 중복 발송을 막기 위해 🫵🏻 Reminded 라벨을 부착하고, 리뷰가 제출되면 discord-notification.yml에서 자동으로 라벨을 제거하도록 했습니다 --- .github/workflows/discord-notification.yml | 11 ++- .../workflows/discord-reviewer-reminder.yml | 93 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/discord-reviewer-reminder.yml diff --git a/.github/workflows/discord-notification.yml b/.github/workflows/discord-notification.yml index 8effad72..15ed1434 100644 --- a/.github/workflows/discord-notification.yml +++ b/.github/workflows/discord-notification.yml @@ -12,10 +12,19 @@ on: jobs: notify: - permissions: {} + permissions: + pull-requests: write + issues: write runs-on: ubuntu-latest steps: + - name: Remove reminded label after review + if: github.event_name == 'pull_request_review' && github.event.action == 'submitted' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr edit "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}" --remove-label "🫵🏻 Reminded" || true + - name: Send Detailed Discord Message shell: bash env: diff --git a/.github/workflows/discord-reviewer-reminder.yml b/.github/workflows/discord-reviewer-reminder.yml new file mode 100644 index 00000000..e10d1795 --- /dev/null +++ b/.github/workflows/discord-reviewer-reminder.yml @@ -0,0 +1,93 @@ +name: ⏰ Discord Reviewer Reminder + +on: + schedule: + - cron: '0 1,6 * * 1-5' # 평일 KST 10:00, 15:00 + workflow_dispatch: + +jobs: + remind: + permissions: + pull-requests: write + issues: write + runs-on: ubuntu-latest + + steps: + - name: Remind pending reviewers + uses: actions/github-script@v7 + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_REMINDER_WEBHOOK_URL }} + with: + script: | + const REMINDER_THRESHOLD_HOURS = 24; + const REMINDED_LABEL = '🫵🏻 Reminded'; + + const NAME_MAP = { + kimminna: '민아', + ehye1: '혜원', + jjangminii: '정민', + 'yumin-kim2': '유민', + }; + const resolveName = (login) => NAME_MAP[login] || login; + + const webhookUrl = process.env.DISCORD_WEBHOOK_URL; + if (!webhookUrl) { + console.log('DISCORD_REMINDER_WEBHOOK_URL not set, skipping'); + return; + } + + const { data: pulls } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100, + }); + + const now = Date.now(); + + for (const pr of pulls) { + if (pr.draft) continue; + + const labels = pr.labels.map((label) => label.name); + if (labels.includes(REMINDED_LABEL)) continue; + + const reviewers = pr.requested_reviewers ?? []; + if (reviewers.length === 0) continue; + + const hoursOpen = (now - new Date(pr.created_at).getTime()) / (1000 * 60 * 60); + if (hoursOpen < REMINDER_THRESHOLD_HOURS) continue; + + const reviewerNames = reviewers.map((reviewer) => resolveName(reviewer.login)).join(', '); + + const payload = { + embeds: [ + { + title: '⏰ 리뷰 리마인드', + url: pr.html_url, + description: `**${pr.title}**\n리뷰 요청 후 ${Math.floor(hoursOpen)}시간째 대기 중이에요.`, + color: 16776960, + fields: [{ name: '리뷰어', value: reviewerNames, inline: true }], + footer: { text: 'GitHub Actions' }, + timestamp: new Date().toISOString(), + }, + ], + }; + + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + console.error(`Discord webhook failed for PR #${pr.number}: ${response.status}`); + continue; + } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: [REMINDED_LABEL], + }); + } From 5f199eedafdb301019faf52bdc130aaf06b6676f Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 26 Aug 2026 15:35:35 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(root):=20=EB=A6=AC=EB=A7=88=EC=9D=B8?= =?UTF-8?q?=EB=8D=94=20=EB=8C=80=EC=83=81=20PR=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EB=A6=AC=EB=B7=B0=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EC=8B=9C=EA=B0=81=20=EA=B8=B0=EC=A4=80=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=84=EA=B3=84=EA=B0=92=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?(#285)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 열린 PR이 100개를 넘어도 전체를 조회하도록 페이지네이션을 적용했습니다 - 대기 시간 계산 기준을 PR 생성 시각에서 리뷰 요청(review_requested) 시각으로 변경했습니다 --- .../workflows/discord-reviewer-reminder.yml | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/workflows/discord-reviewer-reminder.yml b/.github/workflows/discord-reviewer-reminder.yml index e10d1795..f6f1e857 100644 --- a/.github/workflows/discord-reviewer-reminder.yml +++ b/.github/workflows/discord-reviewer-reminder.yml @@ -36,7 +36,7 @@ jobs: return; } - const { data: pulls } = await github.rest.pulls.list({ + const pulls = await github.paginate(github.rest.pulls.list, { owner: context.repo.owner, repo: context.repo.repo, state: 'open', @@ -54,7 +54,30 @@ jobs: const reviewers = pr.requested_reviewers ?? []; if (reviewers.length === 0) continue; - const hoursOpen = (now - new Date(pr.created_at).getTime()) / (1000 * 60 * 60); + const timelineEvents = await github.paginate(github.rest.issues.listEventsForTimeline, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + per_page: 100, + }); + + const reviewRequestTimes = new Map(); + for (const event of timelineEvents) { + if (event.event !== 'review_requested') continue; + const login = event.requested_reviewer?.login; + if (!login) continue; + reviewRequestTimes.set(login, event.created_at); + } + + const requestTimestamps = reviewers + .map((reviewer) => reviewRequestTimes.get(reviewer.login)) + .filter(Boolean) + .map((timestamp) => new Date(timestamp).getTime()); + + const oldestRequestTime = + requestTimestamps.length > 0 ? Math.min(...requestTimestamps) : new Date(pr.created_at).getTime(); + + const hoursOpen = (now - oldestRequestTime) / (1000 * 60 * 60); if (hoursOpen < REMINDER_THRESHOLD_HOURS) continue; const reviewerNames = reviewers.map((reviewer) => resolveName(reviewer.login)).join(', '); From 2698d8d69d7768d66f7dc987a45639b3f76ccb7b Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 26 Aug 2026 15:35:44 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(root):=20=EB=9D=BC=EB=B2=A8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EC=98=88=EC=83=81?= =?UTF-8?q?=EB=90=9C=20=EC=97=90=EB=9F=AC=EB=A7=8C=20=EB=AC=B4=EC=8B=9C?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#285)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 라벨이 없을 때만 무시하고, 인증·권한·네트워크 등 다른 오류는 잡을 실패로 처리했습니다 --- .github/workflows/discord-notification.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/discord-notification.yml b/.github/workflows/discord-notification.yml index 15ed1434..b5f4c5c9 100644 --- a/.github/workflows/discord-notification.yml +++ b/.github/workflows/discord-notification.yml @@ -23,7 +23,14 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - gh pr edit "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}" --remove-label "🫵🏻 Reminded" || true + if ! output=$(gh pr edit "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}" --remove-label "🫵🏻 Reminded" 2>&1); then + if echo "$output" | grep -qi "not found"; then + echo "Label not present, skipping: $output" + else + echo "$output" >&2 + exit 1 + fi + fi - name: Send Detailed Discord Message shell: bash From a8254cd76dfbbbc821b26b764092a466d97a2a75 Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 26 Aug 2026 15:41:49 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(root):=20=EB=A6=AC=EB=A7=88=EC=9D=B8?= =?UTF-8?q?=EB=8D=94=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=20=EC=8B=A4=ED=96=89=20=EB=B0=A9=EC=A7=80=20=EB=B0=8F?= =?UTF-8?q?=20=ED=8C=80=20=EB=A6=AC=EB=B7=B0=EC=96=B4=C2=B7=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=B2=98=EB=A6=AC=20=EB=B3=B4=EA=B0=95=20(#285)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 예약 실행과 수동 실행이 겹치지 않도록 concurrency 설정을 추가했습니다 - 개인 리뷰어뿐 아니라 팀 리뷰 요청도 리마인드 대상에 포함했습니다 - PR별 알림 발송과 라벨 추가 실패가 다른 PR 처리에 영향을 주지 않도록 격리하고 실패 목록을 로그로 남겼습니다 --- .../workflows/discord-reviewer-reminder.yml | 70 +++++++++++++------ 1 file changed, 49 insertions(+), 21 deletions(-) diff --git a/.github/workflows/discord-reviewer-reminder.yml b/.github/workflows/discord-reviewer-reminder.yml index f6f1e857..fa1b21be 100644 --- a/.github/workflows/discord-reviewer-reminder.yml +++ b/.github/workflows/discord-reviewer-reminder.yml @@ -5,6 +5,10 @@ on: - cron: '0 1,6 * * 1-5' # 평일 KST 10:00, 15:00 workflow_dispatch: +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: remind: permissions: @@ -44,6 +48,7 @@ jobs: }); const now = Date.now(); + const failedPrs = []; for (const pr of pulls) { if (pr.draft) continue; @@ -52,7 +57,8 @@ jobs: if (labels.includes(REMINDED_LABEL)) continue; const reviewers = pr.requested_reviewers ?? []; - if (reviewers.length === 0) continue; + const teams = pr.requested_teams ?? []; + if (reviewers.length === 0 && teams.length === 0) continue; const timelineEvents = await github.paginate(github.rest.issues.listEventsForTimeline, { owner: context.repo.owner, @@ -65,12 +71,20 @@ jobs: for (const event of timelineEvents) { if (event.event !== 'review_requested') continue; const login = event.requested_reviewer?.login; - if (!login) continue; - reviewRequestTimes.set(login, event.created_at); + if (login) { + reviewRequestTimes.set(`user:${login}`, event.created_at); + continue; + } + const teamSlug = event.requested_team?.slug; + if (teamSlug) { + reviewRequestTimes.set(`team:${teamSlug}`, event.created_at); + } } - const requestTimestamps = reviewers - .map((reviewer) => reviewRequestTimes.get(reviewer.login)) + const requestTimestamps = [ + ...reviewers.map((reviewer) => reviewRequestTimes.get(`user:${reviewer.login}`)), + ...teams.map((team) => reviewRequestTimes.get(`team:${team.slug}`)), + ] .filter(Boolean) .map((timestamp) => new Date(timestamp).getTime()); @@ -80,7 +94,10 @@ jobs: const hoursOpen = (now - oldestRequestTime) / (1000 * 60 * 60); if (hoursOpen < REMINDER_THRESHOLD_HOURS) continue; - const reviewerNames = reviewers.map((reviewer) => resolveName(reviewer.login)).join(', '); + const reviewerNames = [ + ...reviewers.map((reviewer) => resolveName(reviewer.login)), + ...teams.map((team) => team.name || team.slug), + ].join(', '); const payload = { embeds: [ @@ -96,21 +113,32 @@ jobs: ], }; - const response = await fetch(webhookUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - console.error(`Discord webhook failed for PR #${pr.number}: ${response.status}`); - continue; + try { + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + console.error(`Discord webhook failed for PR #${pr.number}: ${response.status}`); + failedPrs.push(pr.number); + continue; + } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: [REMINDED_LABEL], + }); + } catch (error) { + console.error(`Failed to process PR #${pr.number}: ${error.message}`); + failedPrs.push(pr.number); } + } - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: [REMINDED_LABEL], - }); + if (failedPrs.length > 0) { + console.error(`Failed to fully process PRs: ${failedPrs.join(', ')}`); }