diff --git a/.github/workflows/discord-notification.yml b/.github/workflows/discord-notification.yml index 8effad72..b5f4c5c9 100644 --- a/.github/workflows/discord-notification.yml +++ b/.github/workflows/discord-notification.yml @@ -12,10 +12,26 @@ 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: | + 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 env: diff --git a/.github/workflows/discord-reviewer-reminder.yml b/.github/workflows/discord-reviewer-reminder.yml new file mode 100644 index 00000000..fa1b21be --- /dev/null +++ b/.github/workflows/discord-reviewer-reminder.yml @@ -0,0 +1,144 @@ +name: ⏰ Discord Reviewer Reminder + +on: + schedule: + - cron: '0 1,6 * * 1-5' # 평일 KST 10:00, 15:00 + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +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 pulls = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100, + }); + + const now = Date.now(); + const failedPrs = []; + + 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 ?? []; + 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, + 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) { + 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(`user:${reviewer.login}`)), + ...teams.map((team) => reviewRequestTimes.get(`team:${team.slug}`)), + ] + .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)), + ...teams.map((team) => team.name || team.slug), + ].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(), + }, + ], + }; + + 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); + } + } + + if (failedPrs.length > 0) { + console.error(`Failed to fully process PRs: ${failedPrs.join(', ')}`); + }