Skip to content

PR Reminder

PR Reminder #655

Workflow file for this run

# =============================================================================
# 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}건)"