PR Reminder #655
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ============================================================================= | |
| # PR Reminder (Organization-wide) | |
| # ============================================================================= | |
| # | |
| # 개요: | |
| # Organization 내 모든 레포의 Open PR을 조회하여, 일정 시간 이상 | |
| # 실제 활동(커밋/리뷰/코멘트)이 없는 PR에 remind-needed 라벨을 부착하고, | |
| # PR 코멘트로 리마인드하며, 활동이 재개된 PR에서는 라벨을 제거한다. | |
| # 리마인드 대상 PR이 있으면 하나의 Slack 메시지로 통합 발송한다. | |
| # | |
| # 비활성 판단 기준: | |
| # updated_at이 아닌 마지막 커밋/리뷰/코멘트 시각 중 최신값을 사용. | |
| # Bot 타입 사용자의 코멘트/리뷰는 활동 판단에서 제외하여 피드백 루프를 방지함. | |
| # | |
| # 흐름: | |
| # Schedule (매 2시간) 또는 수동 트리거 | |
| # → GitHub App Token 발급 | |
| # → GitHub Search API로 조직 내 모든 Open PR 조회 | |
| # → PR별 마지막 활동 시각 조회 (커밋/리뷰/코멘트) | |
| # → 4시간 이상 비활성 PR에 remind-needed 라벨 부착 + PR 코멘트 | |
| # → 활성화된 PR에서 remind-needed 라벨 제거 | |
| # → 리마인드 대상 PR을 레포별로 그룹핑하여 Slack 발송 | |
| # | |
| # ----------------------------------------------------------------------------- | |
| # Required Secrets: | |
| # APP_ID - GitHub App ID | |
| # PRIVATE_KEY - GitHub App Private Key | |
| # | |
| # Required Variables: | |
| # ORG_NAME - GitHub Organization 이름 (예: da-bom) | |
| # | |
| # Optional Secrets: | |
| # SLACK_WEBHOOK_URL - Slack Incoming Webhook URL (미설정 시 알림 스킵) | |
| # | |
| # Optional Variables: | |
| # QUIET_HOURS - Slack 알림 차단 시간대 (예: "00-06", 미설정 시 항상 발송) | |
| # QUIET_TIMEZONE - Quiet Hours 기준 타임존 (예: "Asia/Seoul", 기본값: UTC) | |
| # | |
| # GitHub App 추가 권한 필요: | |
| # Pull Requests: Read & Write (라벨 부착/제거) | |
| # ============================================================================= | |
| name: PR Reminder | |
| on: | |
| schedule: | |
| # 매 2시간마다 실행 (UTC 기준) | |
| - cron: '0 */2 * * *' | |
| workflow_dispatch: | |
| jobs: | |
| pr-reminder: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Generate GitHub App Token | |
| id: app-token | |
| uses: actions/create-github-app-token@v1 | |
| with: | |
| app-id: ${{ secrets.APP_ID }} | |
| private-key: ${{ secrets.PRIVATE_KEY }} | |
| owner: ${{ vars.ORG_NAME }} | |
| - name: Check org-wide PRs and manage remind labels | |
| id: check-prs | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| ORG_NAME: ${{ vars.ORG_NAME }} | |
| STALE_HOURS: 4 | |
| REMIND_LABEL: remind-needed | |
| EXEMPT_LABELS: wip,blocked,do-not-remind | |
| run: | | |
| set -euo pipefail | |
| echo "🔍 조직 '${ORG_NAME}' 내 모든 Open PR 조회 중..." | |
| # 현재 시각 기준 STALE_HOURS 이전 시각 계산 (ISO 8601) | |
| STALE_THRESHOLD=$(date -u -d "${STALE_HOURS} hours ago" +%Y-%m-%dT%H:%M:%SZ) | |
| echo "⏱️ 비활성 기준 시각: ${STALE_THRESHOLD}" | |
| # 조직 내 모든 Open PR 조회 (draft 제외) | |
| # GitHub Search API: is:pr is:open org:<org> draft:false | |
| ALL_PRS=$(gh api --paginate "search/issues?q=is:pr+is:open+org:${ORG_NAME}+draft:false&per_page=100" \ | |
| --jq '.items[] | { | |
| number: .number, | |
| title: .title, | |
| html_url: .html_url, | |
| user: .user.login, | |
| updated_at: .updated_at, | |
| repo: (.repository_url | split("/") | last), | |
| repo_full: (.repository_url | split("/")[-2:] | join("/")), | |
| labels: [.labels[].name] | |
| }' | jq -s '.') | |
| PR_COUNT=$(echo "$ALL_PRS" | jq 'length') | |
| echo "📋 조회된 Open PR 수: ${PR_COUNT}" | |
| # 제외 라벨 목록을 JSON 배열로 변환 | |
| EXEMPT_ARRAY=$(echo "$EXEMPT_LABELS" | jq -R 'split(",")') | |
| STALE_PRS="[]" | |
| FRESH_PRS_WITH_LABEL="[]" | |
| for i in $(seq 0 $((PR_COUNT - 1))); do | |
| PR=$(echo "$ALL_PRS" | jq ".[$i]") | |
| PR_LABELS=$(echo "$PR" | jq '.labels') | |
| REPO_FULL=$(echo "$PR" | jq -r '.repo_full') | |
| PR_NUMBER=$(echo "$PR" | jq -r '.number') | |
| HAS_REMIND=$(echo "$PR_LABELS" | jq --arg label "$REMIND_LABEL" 'any(. == $label)') | |
| HAS_EXEMPT=$(echo "$PR_LABELS" | jq --argjson exempt "$EXEMPT_ARRAY" 'any(. as $l | $exempt | any(. == $l))') | |
| # 제외 라벨이 있으면 스킵 | |
| if [ "$HAS_EXEMPT" = "true" ]; then | |
| continue | |
| fi | |
| # 마지막 의미 있는 활동 시각 조회 (커밋, 리뷰, 코멘트) | |
| # Bot 사용자의 코멘트/리뷰는 제외하여 리마인드 코멘트로 인한 피드백 루프 방지 | |
| LAST_COMMIT=$(gh api "repos/${REPO_FULL}/pulls/${PR_NUMBER}/commits" \ | |
| --jq 'last | .commit.committer.date // empty' 2>/dev/null || echo "") | |
| LAST_REVIEW=$(gh api "repos/${REPO_FULL}/pulls/${PR_NUMBER}/reviews" \ | |
| --jq '[.[] | select(.user.type != "Bot")] | last | .submitted_at // empty' 2>/dev/null || echo "") | |
| LAST_COMMENT=$(gh api "repos/${REPO_FULL}/issues/${PR_NUMBER}/comments" \ | |
| --jq '[.[] | select(.user.type != "Bot")] | last | .created_at // empty' 2>/dev/null || echo "") | |
| LAST_ACTIVITY=$(printf '%s\n' "$LAST_COMMIT" "$LAST_REVIEW" "$LAST_COMMENT" \ | |
| | grep -v '^$' | sort -r | head -1) | |
| # 활동 기록이 전혀 없으면 PR 생성 시각(updated_at) 사용 | |
| if [ -z "$LAST_ACTIVITY" ]; then | |
| LAST_ACTIVITY=$(echo "$PR" | jq -r '.updated_at') | |
| fi | |
| # 비활성 여부 판단 (실제 활동 기반) | |
| if [[ "$LAST_ACTIVITY" < "$STALE_THRESHOLD" ]]; then | |
| # 비활성 PR → 라벨 부착 + 코멘트 | |
| STALE_PRS=$(echo "$STALE_PRS" | jq --argjson pr "$PR" '. + [$pr]') | |
| if [ "$HAS_REMIND" = "false" ]; then | |
| echo "🏷️ 라벨 부착: ${REPO_FULL}#${PR_NUMBER}" | |
| gh api --method POST \ | |
| "repos/${REPO_FULL}/issues/${PR_NUMBER}/labels" \ | |
| -f "labels[]=${REMIND_LABEL}" > /dev/null 2>&1 || true | |
| # PR 코멘트 작성 (최초 라벨 부착 시에만) | |
| COMMENT_BODY=$'👋 **PR 리마인드 알림입니다**\n\n이 PR이 몇 시간 동안 업데이트되지 않았어요.\n\n- 🙋 **PR 작성자**: 추가 작업 중이거나 설명이 필요하면 코멘트 남겨주세요\n- 👀 **리뷰어 / 팀원**: 이미 봤다면 LGTM 한 줄도 좋아요!\n\n바쁘실 수 있다는 점 이해합니다 🙏\n다만 작은 한 줄이 PR 흐름을 살려줘요 🙂' | |
| gh api --method POST \ | |
| "repos/${REPO_FULL}/issues/${PR_NUMBER}/comments" \ | |
| -f body="$COMMENT_BODY" > /dev/null 2>&1 || true | |
| fi | |
| else | |
| # 활성 PR인데 remind 라벨이 있으면 제거 | |
| if [ "$HAS_REMIND" = "true" ]; then | |
| echo "🔄 라벨 제거: ${REPO_FULL}#${PR_NUMBER}" | |
| gh api --method DELETE \ | |
| "repos/${REPO_FULL}/issues/${PR_NUMBER}/labels/${REMIND_LABEL}" \ | |
| > /dev/null 2>&1 || true | |
| FRESH_PRS_WITH_LABEL=$(echo "$FRESH_PRS_WITH_LABEL" | jq --argjson pr "$PR" '. + [$pr]') | |
| fi | |
| fi | |
| done | |
| STALE_COUNT=$(echo "$STALE_PRS" | jq 'length') | |
| FRESH_REMOVED=$(echo "$FRESH_PRS_WITH_LABEL" | jq 'length') | |
| echo "" | |
| echo "=========================================" | |
| echo " 결과 요약" | |
| echo "=========================================" | |
| echo " 전체 Open PR: ${PR_COUNT}" | |
| echo " 비활성 (리마인드): ${STALE_COUNT}" | |
| echo " 라벨 제거 (활성): ${FRESH_REMOVED}" | |
| echo "=========================================" | |
| # 다음 스텝에서 사용할 수 있도록 출력 | |
| echo "stale_count=${STALE_COUNT}" >> $GITHUB_OUTPUT | |
| # JSON 데이터를 파일로 저장 (Slack 메시지 구성용) | |
| echo "$STALE_PRS" > /tmp/stale_prs.json | |
| - name: Send Slack reminder | |
| if: steps.check-prs.outputs.stale_count != '0' | |
| env: | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| QUIET_HOURS: ${{ vars.QUIET_HOURS }} | |
| QUIET_TIMEZONE: ${{ vars.QUIET_TIMEZONE }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${SLACK_WEBHOOK_URL:-}" ]; then | |
| echo "⚠️ SLACK_WEBHOOK_URL이 설정되지 않아 슬랙 알림을 스킵합니다." | |
| exit 0 | |
| fi | |
| # Quiet Hours 체크 (설정된 시간대에는 Slack 알림 스킵) | |
| QUIET_HOURS="${QUIET_HOURS:-}" | |
| QUIET_TZ="${QUIET_TIMEZONE:-UTC}" | |
| if [ -n "$QUIET_HOURS" ]; then | |
| QUIET_START=$(echo "$QUIET_HOURS" | cut -d'-' -f1 | sed 's/^0*//') | |
| QUIET_END=$(echo "$QUIET_HOURS" | cut -d'-' -f2 | sed 's/^0*//') | |
| CURRENT_HOUR=$(TZ="$QUIET_TZ" date +%H | sed 's/^0*//') | |
| # 00시 처리 (sed로 빈 문자열이 되는 경우) | |
| QUIET_START=${QUIET_START:-0} | |
| QUIET_END=${QUIET_END:-0} | |
| CURRENT_HOUR=${CURRENT_HOUR:-0} | |
| if [ "$QUIET_START" -le "$QUIET_END" ]; then | |
| # 일반 범위: 00-06 → 0시~5시 | |
| IN_QUIET=$(( CURRENT_HOUR >= QUIET_START && CURRENT_HOUR < QUIET_END )) | |
| else | |
| # 자정 경과 범위: 22-06 → 22시~5시 | |
| IN_QUIET=$(( CURRENT_HOUR >= QUIET_START || CURRENT_HOUR < QUIET_END )) | |
| fi | |
| if [ "$IN_QUIET" -eq 1 ]; then | |
| echo "🌙 Quiet Hours (${QUIET_HOURS} ${QUIET_TZ}) - Slack 알림을 스킵합니다." | |
| exit 0 | |
| fi | |
| fi | |
| STALE_PRS=$(cat /tmp/stale_prs.json) | |
| STALE_COUNT=$(echo "$STALE_PRS" | jq 'length') | |
| # 레포별로 그룹핑하여 PR 목록 생성 | |
| PR_LIST=$(echo "$STALE_PRS" | jq -r ' | |
| group_by(.repo) | | |
| map( | |
| "📂 *" + .[0].repo + "*\n" + | |
| (map(" - <" + .html_url + "|#" + (.number | tostring) + "> " + .title + " 👤 " + .user) | join("\n")) | |
| ) | join("\n\n")') | |
| MESSAGE="*👀 PR Reminder* | |
| 최근 *업데이트가 없는 PR*이 *${STALE_COUNT}건* 있어요 ⏳ | |
| 짧게라도 확인 부탁드려요! | |
| ━━━━━━━━━━━━━━ | |
| ${PR_LIST} | |
| ━━━━━━━━━━━━━━ | |
| 👉 *PR 작성자*: 진행 상황 공유 또는 추가 설명 부탁드려요 | |
| 👉 *리뷰어 / 팀원*: 이미 봤다면 👍 LGTM 한 줄이면 충분해요! | |
| 🙏 작은 액션 하나가 PR 흐름을 살려줘요" | |
| # Slack 전송 (jq로 JSON escape 처리) | |
| curl -fsS -X POST -H 'Content-type: application/json' \ | |
| --data "$(jq -n --arg text "$MESSAGE" '{text: $text}')" \ | |
| "$SLACK_WEBHOOK_URL" | |
| echo "✅ Slack 리마인드 메시지 전송 완료 (${STALE_COUNT}건)" |