diff --git a/.github/workflows/discord-pr-bot.yml b/.github/workflows/discord-pr-bot.yml index b0c3a049..911a1a8e 100644 --- a/.github/workflows/discord-pr-bot.yml +++ b/.github/workflows/discord-pr-bot.yml @@ -78,62 +78,25 @@ jobs: echo "base_ref=$BASE_REF" >> "$GITHUB_OUTPUT" echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT" - - name: Find existing Discord message/thread from PR comments - id: find_meta + - name: Prepare shared helpers + # post_parent·update 에 복붙돼 있던 헬퍼 4개를 공유 스크립트로 추출한다(#661 nitpick). checkout 없이 + # 같은 job 의 러너 파일시스템($RUNNER_TEMP)으로 공유하고, 함수가 참조하는 REPO·DISCORD_USER_MAP 은 + # GITHUB_ENV 로 이후 스텝에 넘긴다(GH_TOKEN 은 각 스텝이 secrets 로 받는다). env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - COMMENTS=$(curl -s \ - -H "Authorization: Bearer $GH_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/${{ github.repository }}/issues/${{ steps.pr.outputs.pr_number }}/comments") - - # 메타 주석 본문(마지막 것)에서 JSON 객체를 뽑는다. initial_reviewers 는 배열([])이라 중괄호 중첩이 없어 - # `\{[^}]*\}` 로 객체 하나를 통째로 잡을 수 있다. - BODY=$(echo "$COMMENTS" | jq -r '.[]? | objects | select((.body // "") | contains("discord-pr-bot")) | .body' | tail -n 1) - # 신규 PR 은 아직 메타 주석이 없어 grep 이 no-match(exit 1)로 끝난다. set -eo pipefail 이 이를 실패로 보고 스텝을 - # 죽이므로 `|| true` 로 방어한다. 없으면 META_JSON 은 빈 문자열이고, 빈 입력에 jq 를 물리면 파싱 에러가 나므로 - # META_JSON 이 있을 때만 파싱한다. - META_JSON=$(echo "$BODY" | grep -oP 'discord-pr-bot:\s*\K\{[^}]*\}' | tail -n 1 || true) - - MESSAGE_ID=""; THREAD_ID=""; CHANNEL_ID=""; INITIAL_REVIEWERS="[]" - if [ -n "$META_JSON" ]; then - MESSAGE_ID=$(echo "$META_JSON" | jq -r '.message_id // empty') - THREAD_ID=$(echo "$META_JSON" | jq -r '.thread_id // empty') - CHANNEL_ID=$(echo "$META_JSON" | jq -r '.channel_id // empty') - INITIAL_REVIEWERS=$(echo "$META_JSON" | jq -c '.initial_reviewers // []') - fi - - echo "message_id=$MESSAGE_ID" >> "$GITHUB_OUTPUT" - echo "thread_id=$THREAD_ID" >> "$GITHUB_OUTPUT" - echo "channel_id=$CHANNEL_ID" >> "$GITHUB_OUTPUT" - echo "initial_reviewers=$INITIAL_REVIEWERS" >> "$GITHUB_OUTPUT" - - - name: Post parent message and create thread - if: steps.find_meta.outputs.message_id == '' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.draft == false - id: post_parent - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_TITLE: ${{ steps.pr.outputs.pr_title }} - HEAD_REF: ${{ steps.pr.outputs.head_ref }} - BASE_REF: ${{ steps.pr.outputs.base_ref }} + DISCORD_USER_MAP: ${{ secrets.DISCORD_USER_MAP }} + REPO: ${{ github.repository }} shell: bash run: | [ -n "$DISCORD_USER_MAP" ] || DISCORD_USER_MAP='{}' - - # github 로그인 → discord 멘션(매핑 없으면 @로그인명, 핑 안 감). + cat > "$RUNNER_TEMP/discord-pr-lib.sh" <<'LIB' + # github 로그인 → discord 멘션(매핑 없으면 @로그인명, 핑 안 감). $DISCORD_USER_MAP 참조. mention_discord_user() { - local github_id="$1" - local discord_id + local github_id="$1" discord_id discord_id=$(echo "$DISCORD_USER_MAP" | jq -r --arg id "$github_id" '.[$id] // empty') - if [ -n "$discord_id" ]; then - echo "<@$discord_id>" - else - echo "@$github_id" - fi + if [ -n "$discord_id" ]; then echo "<@$discord_id>"; else echo "@$github_id"; fi } + # requested_reviewers → "a, b" 멘션 목록(없으면 "없음"). $GITHUB_EVENT_PATH 참조. build_reviewer_text() { local reviewers reviewer_text user_mention reviewers=$(jq -r '.pull_request.requested_reviewers[]?.login' "$GITHUB_EVENT_PATH") @@ -148,6 +111,14 @@ jobs: echo "$reviewer_text" } + # PR 라벨을 "a, b" 목록으로(없으면 "없음"). $GITHUB_EVENT_PATH 참조. + build_label_text() { + local labels + labels=$(jq -r '[.pull_request.labels[]?.name] | join(", ")' "$GITHUB_EVENT_PATH") + [ -z "$labels" ] && echo "없음" || echo "$labels" + } + + # PR 본문의 close #N 을 뽑아 각 이슈 상태(🟢열림/⚫닫힘)와 링크 목록으로. $GH_TOKEN·$REPO 참조. build_issue_text() { local pr_body="$1" issues issue_text num res state emoji link line issues=$(echo "$pr_body" | grep -oiE '\bclose[sd]?[[:space:]]+#[0-9]+' | grep -oE '#[0-9]+' | sort -u || true) @@ -157,11 +128,10 @@ jobs: [ -z "$issue" ] && continue num=$(echo "$issue" | tr -d '#') res=$(curl -s -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/${{ github.repository }}/issues/$num") + "https://api.github.com/repos/$REPO/issues/$num") state=$(echo "$res" | jq -r '.state // "unknown"') if [ "$state" = "open" ]; then emoji="🟢"; elif [ "$state" = "closed" ]; then emoji="⚫"; else emoji="❓"; fi - # Discord 마크다운: 링크는 [text](url). - link="https://github.com/${{ github.repository }}/issues/$num" + link="https://github.com/$REPO/issues/$num" line="$emoji [#$num]($link)" if [ -z "$issue_text" ]; then issue_text="$line"; else issue_text=$(printf "%s\n%s" "$issue_text" "$line"); fi done <<< "$issues" @@ -169,6 +139,7 @@ jobs: echo "$issue_text" } + # PR 본문의 ## 작업 요약 섹션 불릿을 "• ..." 요약으로(없으면 "요약 없음"). 순수(인자만). build_summary() { local pr_body="$1" printf '%s\n' "$pr_body" | awk ' @@ -196,9 +167,63 @@ jobs: END { if (n > 0) print out; else print "요약 없음" } ' } + LIB + { + echo "DISCORD_USER_MAP<<__PKENV_EOF__" + echo "$DISCORD_USER_MAP" + echo "__PKENV_EOF__" + echo "REPO=$REPO" + } >> "$GITHUB_ENV" + + - name: Find existing Discord message/thread from PR comments + id: find_meta + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + COMMENTS=$(curl -s \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${{ github.repository }}/issues/${{ steps.pr.outputs.pr_number }}/comments") + + # 메타 주석 본문(마지막 것)에서 JSON 객체를 뽑는다. select 는 `discord-pr-bot: {`(JSON 시작) 마커까지 요구한다. + # 느슨한 contains("discord-pr-bot") 는 discord-pr-bot.yml 파일명을 언급한 CodeRabbit·사람 리뷰 코멘트까지 잡아, + # tail -n 1 이 그 코멘트를 집으면 JSON 이 없어 metadata 를 못 읽고 이후 스텝이 전부 skip 되던 버그가 있었다. + # initial_reviewers 는 배열([])이라 중괄호 중첩이 없어 `\{[^}]*\}` 로 객체 하나를 통째로 잡을 수 있다. + BODY=$(echo "$COMMENTS" | jq -r '.[]? | objects | select((.body // "") | test("discord-pr-bot:\\s*\\{")) | .body' | tail -n 1) + # 신규 PR 은 아직 메타 주석이 없어 grep 이 no-match(exit 1)로 끝난다. set -eo pipefail 이 이를 실패로 보고 스텝을 + # 죽이므로 `|| true` 로 방어한다. 없으면 META_JSON 은 빈 문자열이고, 빈 입력에 jq 를 물리면 파싱 에러가 나므로 + # META_JSON 이 있을 때만 파싱한다. + META_JSON=$(echo "$BODY" | grep -oP 'discord-pr-bot:\s*\K\{[^}]*\}' | tail -n 1 || true) + + MESSAGE_ID=""; THREAD_ID=""; CHANNEL_ID=""; INITIAL_REVIEWERS="[]" + if [ -n "$META_JSON" ]; then + MESSAGE_ID=$(echo "$META_JSON" | jq -r '.message_id // empty') + THREAD_ID=$(echo "$META_JSON" | jq -r '.thread_id // empty') + CHANNEL_ID=$(echo "$META_JSON" | jq -r '.channel_id // empty') + INITIAL_REVIEWERS=$(echo "$META_JSON" | jq -c '.initial_reviewers // []') + fi + + echo "message_id=$MESSAGE_ID" >> "$GITHUB_OUTPUT" + echo "thread_id=$THREAD_ID" >> "$GITHUB_OUTPUT" + echo "channel_id=$CHANNEL_ID" >> "$GITHUB_OUTPUT" + echo "initial_reviewers=$INITIAL_REVIEWERS" >> "$GITHUB_OUTPUT" + + - name: Post parent message and create thread + if: steps.find_meta.outputs.message_id == '' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.draft == false + id: post_parent + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_TITLE: ${{ steps.pr.outputs.pr_title }} + HEAD_REF: ${{ steps.pr.outputs.head_ref }} + BASE_REF: ${{ steps.pr.outputs.base_ref }} + shell: bash + run: | + source "$RUNNER_TEMP/discord-pr-lib.sh" PR_BODY=$(jq -r '.pull_request.body // ""' "$GITHUB_EVENT_PATH") REVIEWER_TEXT=$(build_reviewer_text) + LABEL_TEXT=$(build_label_text) ISSUE_TEXT=$(build_issue_text "$PR_BODY") SUMMARY=$(build_summary "$PR_BODY") @@ -219,6 +244,7 @@ jobs: **Author**: ${{ steps.pr.outputs.author }} **Branch**: $HEAD_REF -> $BASE_REF **Reviewer**: $REVIEWER_TEXT + **Label**: $LABEL_TEXT EOF ) # Discord content 2000자 제한 — 넘치면 POST 가 400 → 실패. 여유 두고 1900자로 자른다 (러너 UTF-8 로케일 char 기준). @@ -256,7 +282,7 @@ jobs: run: | INITIAL_REVIEWERS=$(jq -c '[.pull_request.requested_reviewers[]?.login]' "$GITHUB_EVENT_PATH") META="" - # visible 안내문 + 숨겨진 metadata 주석. find_meta 의 contains("discord-pr-bot") 검색이 주석을 잡으므로 연동에 영향 없다. + # visible 안내문 + 숨겨진 metadata 주석. find_meta 가 `discord-pr-bot: {` 마커로 이 주석을 잡으므로 연동에 영향 없다. BODY=$(printf '%s\n%s' 'Discord 스레드 연동용 메타데이터입니다. discord-pr-bot 워크플로가 자동 생성하며, 수정·삭제하면 PR 과 Discord 알림 연동이 끊깁니다.' "$META") curl -s -X POST \ @@ -274,81 +300,13 @@ jobs: BASE_REF: ${{ steps.pr.outputs.base_ref }} shell: bash run: | - [ -n "$DISCORD_USER_MAP" ] || DISCORD_USER_MAP='{}' - - mention_discord_user() { - local github_id="$1" - local discord_id - discord_id=$(echo "$DISCORD_USER_MAP" | jq -r --arg id "$github_id" '.[$id] // empty') - if [ -n "$discord_id" ]; then echo "<@$discord_id>"; else echo "@$github_id"; fi - } - - build_reviewer_text() { - local reviewers reviewer_text user_mention - reviewers=$(jq -r '.pull_request.requested_reviewers[]?.login' "$GITHUB_EVENT_PATH") - reviewer_text="" - if [ -z "$reviewers" ]; then echo "없음"; return; fi - while IFS= read -r reviewer; do - [ -z "$reviewer" ] && continue - user_mention=$(mention_discord_user "$reviewer") - if [ -z "$reviewer_text" ]; then reviewer_text="$user_mention"; else reviewer_text="$reviewer_text, $user_mention"; fi - done <<< "$reviewers" - [ -z "$reviewer_text" ] && reviewer_text="없음" - echo "$reviewer_text" - } - - build_issue_text() { - local pr_body="$1" issues issue_text num res state emoji link line - issues=$(echo "$pr_body" | grep -oiE '\bclose[sd]?[[:space:]]+#[0-9]+' | grep -oE '#[0-9]+' | sort -u || true) - issue_text="" - if [ -z "$issues" ]; then echo "없음"; return; fi - while IFS= read -r issue; do - [ -z "$issue" ] && continue - num=$(echo "$issue" | tr -d '#') - res=$(curl -s -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/${{ github.repository }}/issues/$num") - state=$(echo "$res" | jq -r '.state // "unknown"') - if [ "$state" = "open" ]; then emoji="🟢"; elif [ "$state" = "closed" ]; then emoji="⚫"; else emoji="❓"; fi - link="https://github.com/${{ github.repository }}/issues/$num" - line="$emoji [#$num]($link)" - if [ -z "$issue_text" ]; then issue_text="$line"; else issue_text=$(printf "%s\n%s" "$issue_text" "$line"); fi - done <<< "$issues" - [ -z "$issue_text" ] && issue_text="없음" - echo "$issue_text" - } - - build_summary() { - local pr_body="$1" - printf '%s\n' "$pr_body" | awk ' - BEGIN { in_task=0; n=0; out="" } - /^##/ { - if (in_task == 1) exit - r = $0 - sub(/^##[[:space:]]+/, "", r) - ok = (match(r, /^작업 요약([^A-Za-z]|$)/) || match(r, /[[:space:]]작업 요약([^A-Za-z]|$)/)) - if (ok) { in_task=1; next } - next - } - in_task == 1 && /^##/ { exit } - in_task == 1 { - if ($0 ~ /^[[:space:]]+-/) next - s = $0 - sub(/^[[:space:]]+/, "", s) - if (s ~ /^>/) next - if (s ~ /^-[[:space:]]*\[[[:space:]xX]\]/) next - if (s ~ /^-[[:space:]]+/) { - sub(/^-[[:space:]]+/, "", s) - if (length(s) > 0) { if (n++ > 0) out = out "\n"; out = out "• " s } - } - } - END { if (n > 0) print out; else print "요약 없음" } - ' - } + source "$RUNNER_TEMP/discord-pr-lib.sh" CHANNEL_ID="${{ steps.find_meta.outputs.channel_id }}" MESSAGE_ID="${{ steps.find_meta.outputs.message_id }}" PR_BODY=$(jq -r '.pull_request.body // ""' "$GITHUB_EVENT_PATH") REVIEWER_TEXT=$(build_reviewer_text) + LABEL_TEXT=$(build_label_text) ISSUE_TEXT=$(build_issue_text "$PR_BODY") SUMMARY=$(build_summary "$PR_BODY") STATUS_LINE="" @@ -374,6 +332,7 @@ jobs: **Author**: ${{ steps.pr.outputs.author }} **Branch**: $HEAD_REF -> $BASE_REF **Reviewer**: $REVIEWER_TEXT + **Label**: $LABEL_TEXT EOF ) TEXT="${TEXT:0:1900}" @@ -395,19 +354,12 @@ jobs: BASE_REF: ${{ steps.pr.outputs.base_ref }} shell: bash run: | - [ -n "$DISCORD_USER_MAP" ] || DISCORD_USER_MAP='{}' + source "$RUNNER_TEMP/discord-pr-lib.sh" THREAD_ID="${{ steps.find_meta.outputs.thread_id }}" ACTION="${{ steps.pr.outputs.action }}" MERGED="${{ steps.pr.outputs.merged }}" - mention_discord_user() { - local github_id="$1" - local discord_id - discord_id=$(echo "$DISCORD_USER_MAP" | jq -r --arg id "$github_id" '.[$id] // empty') - if [ -n "$discord_id" ]; then echo "<@$discord_id>"; else echo "@$github_id"; fi - } - TEXT="" if [ "$ACTION" = "review_requested" ]; then @@ -432,12 +384,12 @@ jobs: TEXT="➖ 리뷰 요청이 해제되었습니다." fi elif [ "$ACTION" = "synchronize" ]; then - # 커밋 수는 이벤트 payload 의 .pull_request.commits 를 그대로 쓴다 — PR 커밋 API 는 30개/페이지라 - # 첫 페이지 length 로 세면 31개 이상 PR 에서 개수가 30 으로 잘린다. - COMMIT_COUNT=$(jq -r '.pull_request.commits // 0' "$GITHUB_EVENT_PATH") + # 커밋 수는 PR 페이로드가 직접 주는 정확한 총수(.pull_request.commits)를 쓴다. commits_url 을 fetch 해 + # length 로 세면 페이지네이션 상한(기본 30)에 잘려 커밋 30개 넘는 PR 이 "30 commits"로 오표기된다. + COMMIT_COUNT=$(jq -r '.pull_request.commits' "$GITHUB_EVENT_PATH") + # 최신 커밋 메시지는 PR commits API 의 마지막 페이지(per_page=1 & page=총개수)로 집는다. commits/{sha} 단건 + # 조회는 contents:read 가 필요하나 이 워크플로는 pull-requests 권한만 있어, PR commits 엔드포인트를 쓴다. COMMITS_URL=$(jq -r '.pull_request.commits_url' "$GITHUB_EVENT_PATH") - # 최신 커밋도 첫 페이지 .[-1] 은 오래된→최신 순 페이지네이션이라 31개 이상이면 최신이 아니다. - # 마지막 페이지(per_page=1 & page=총개수)를 집어 실제 최신 커밋 하나만 가져온다. LAST_COMMIT_MSG=$(curl -s -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "${COMMITS_URL}?per_page=1&page=${COMMIT_COUNT}" | jq -r 'if type=="array" and length>0 then (.[-1].commit.message // "") else "" end' | head -n 1) if [ -n "$LAST_COMMIT_MSG" ] && [ "$LAST_COMMIT_MSG" != "null" ]; then TEXT=$(printf '🔄 새 커밋 추가됨 (%s commits)\n• %s' "$COMMIT_COUNT" "$LAST_COMMIT_MSG") @@ -460,3 +412,126 @@ jobs: if [ -z "$OK" ]; then echo "Discord thread reply failed"; echo "$RESP"; exit 1 fi + + - name: Manage status reaction (전환 - 현재 상태 하나만) + # 부모 메시지가 있어야(신규는 post_parent, 기존은 find_meta) 리액션을 단다. review_requested 등 + # 상태 전환이 없는 이벤트는 아래 case 에서 스킵한다(리액션은 큰 상태만, 세부는 스레드 답글). + if: steps.find_meta.outputs.message_id != '' || steps.post_parent.outputs.message_id != '' + env: + ACTION: ${{ steps.pr.outputs.action }} + MERGED: ${{ steps.pr.outputs.merged }} + shell: bash + run: | + MSG_ID="${{ steps.find_meta.outputs.message_id }}" + [ -z "$MSG_ID" ] && MSG_ID="${{ steps.post_parent.outputs.message_id }}" + CH_ID="${{ steps.find_meta.outputs.channel_id }}" + [ -z "$CH_ID" ] && CH_ID="$DISCORD_CHANNEL_ID" + [ -z "$MSG_ID" ] && { echo "부모 메시지 없음 - 리액션 생략"; exit 0; } + + # PiKi 상태 이모지 (name:id). 전환 방식이라 매 이벤트에 이전 상태를 지우고 현재 하나만 남긴다. + EMOJI_OPEN="piki_open:1523200235701014528" + EMOJI_WIP="piki_wip:1523200286758539264" + EMOJI_DONE="piki_done:1523200333482823720" + EMOJI_CLOSED="piki_closed:1523200373899132928" + ALL="$EMOJI_OPEN $EMOJI_WIP $EMOJI_DONE $EMOJI_CLOSED" + + reaction_api() { + # $1: HTTP 메서드, $2: name:id 이모지. 봇 자신의 리액션(@me)만 조작한다. + # Discord reaction API 는 rate limit 이 빡빡해(이전 상태 DELETE 3 + 현재 PUT 1 을 몰아 호출하면 429), + # 429 면 응답 body 의 retry_after(초, 소수 가능)만큼 쉬고 재시도한다(최대 5회). 소진 시 마지막 코드를 돌려준다. + local method="$1" emoji="$2" url resp code retry + url="$DISCORD_API/channels/$CH_ID/messages/$MSG_ID/reactions/$emoji/@me" + for _ in 1 2 3 4 5; do + resp=$(curl -s -w '\n%{http_code}' -X "$method" "$url" -H "Authorization: Bot $DISCORD_BOT_TOKEN") + code=$(printf '%s' "$resp" | tail -n1) + [ "$code" != "429" ] && { printf '%s' "$code"; return; } + # 429 body: {"message":..,"retry_after":0.5,..}. 없거나 비정상이면 1 초. + retry=$(printf '%s' "$resp" | sed '$d' | jq -r '.retry_after // 1' 2>/dev/null) + case "$retry" in ''|*[!0-9.]*) retry=1 ;; esac + sleep "$retry" + done + printf '%s' "$code" + } + + # 이번 이벤트의 현재 상태. 전환 없는 이벤트(review_requested 등)는 상태를 안 바꾸고 끝낸다. + CURRENT="" + case "$ACTION" in + opened|reopened) CURRENT="$EMOJI_OPEN" ;; + synchronize) CURRENT="$EMOJI_WIP" ;; + closed) + if [ "$MERGED" = "true" ]; then CURRENT="$EMOJI_DONE"; else CURRENT="$EMOJI_CLOSED"; fi ;; + *) echo "상태 전환 없는 이벤트($ACTION) - 리액션 유지"; exit 0 ;; + esac + + # 이전 상태 제거(없으면 Discord 가 무시) 후 현재 하나만 추가. + for e in $ALL; do + [ "$e" = "$CURRENT" ] && continue + reaction_api DELETE "$e" >/dev/null + done + CODE=$(reaction_api PUT "$CURRENT") + # 성공은 204. 실패해도 알림 본체는 이미 나갔으므로 워크플로를 깨지 않고 로그만 남긴다. + [ "$CODE" = "204" ] && echo "리액션 설정: $CURRENT" || echo "::warning::리액션 실패 (HTTP $CODE): $CURRENT" + + - name: Pin parent on open (열린 PR 채널 상단 고정) + # 열린(리뷰 대기) PR 부모를 채널 상단에 고정해 눈에 띄게 한다. 고정은 채널당 50개 한도라, 초과 시 + # PUT 이 실패해도(경고만) 알림 본체엔 영향을 주지 않는다. 닫힐 때 아래 스텝이 해제한다. + # reopened 는 메타가 이미 있어 post_parent 가 스킵되므로, 부모 id 를 find_meta 로 폴백해 재고정한다. + if: (github.event.action == 'opened' || github.event.action == 'reopened') && (steps.post_parent.outputs.message_id != '' || steps.find_meta.outputs.message_id != '') + shell: bash + run: | + MSG_ID="${{ steps.post_parent.outputs.message_id }}" + [ -z "$MSG_ID" ] && MSG_ID="${{ steps.find_meta.outputs.message_id }}" + CH_ID="${{ steps.find_meta.outputs.channel_id }}" + [ -z "$CH_ID" ] && CH_ID="$DISCORD_CHANNEL_ID" + CODE=$(curl -s -o /dev/null -w '%{http_code}' -X PUT \ + "$DISCORD_API/channels/$CH_ID/pins/$MSG_ID" \ + -H "Authorization: Bot $DISCORD_BOT_TOKEN") + [ "$CODE" = "204" ] && echo "고정됨" || echo "::warning::고정 실패 (HTTP $CODE) - 채널 고정 50개 한도일 수 있음" + + - name: Restore thread on reopen (재오픈 시 스레드 복구) + # 종료 시 아래 스텝이 스레드를 archived·locked 로 잠그고 이름에 🗑️ 프리픽스를 단다. 재오픈되면 그대로일 경우 + # 이후 synchronize 답글이 잠긴 스레드라 실패하므로, unarchive·unlock 하고 이름을 원래 형식(프리픽스 없음)으로 되돌린다. + if: github.event.action == 'reopened' && steps.find_meta.outputs.thread_id != '' + env: + PR_TITLE: ${{ steps.pr.outputs.pr_title }} + shell: bash + run: | + THREAD_ID="${{ steps.find_meta.outputs.thread_id }}" + NEW_NAME=$(printf 'PR #%s %s' "${{ steps.pr.outputs.pr_number }}" "$PR_TITLE" | cut -c1-90) + CODE=$(curl -s -o /dev/null -w '%{http_code}' -X PATCH \ + "$DISCORD_API/channels/$THREAD_ID" \ + -H "Authorization: Bot $DISCORD_BOT_TOKEN" \ + -H "Content-Type: application/json; charset=utf-8" \ + --data "$(jq -n --arg name "$NEW_NAME" '{name:$name, archived:false, locked:false}')") + [ "$CODE" = "200" ] && echo "스레드 복구됨: $NEW_NAME" || echo "::warning::스레드 복구 실패 (HTTP $CODE)" + + - name: Unpin and archive thread on close (종료 시 정리) + # 머지/종료 시: 부모 고정 해제 + 스레드 이름에 결과 프리픽스(✅/🗑️) + archive·lock 으로 정리한다. + if: github.event.action == 'closed' && steps.find_meta.outputs.message_id != '' + env: + MERGED: ${{ steps.pr.outputs.merged }} + PR_TITLE: ${{ steps.pr.outputs.pr_title }} + shell: bash + run: | + CH_ID="${{ steps.find_meta.outputs.channel_id }}" + [ -z "$CH_ID" ] && CH_ID="$DISCORD_CHANNEL_ID" + MSG_ID="${{ steps.find_meta.outputs.message_id }}" + THREAD_ID="${{ steps.find_meta.outputs.thread_id }}" + + # 고정 해제 (열린 PR 만 상단에 두므로, 닫히면 내린다). 없어도 무해. + if [ -n "$MSG_ID" ]; then + curl -s -o /dev/null -X DELETE "$DISCORD_API/channels/$CH_ID/pins/$MSG_ID" \ + -H "Authorization: Bot $DISCORD_BOT_TOKEN" + fi + + # 스레드: 이름 앞에 결과 프리픽스 + archive + lock. 이름은 100자 제한이라 90자로 자른다. + if [ -n "$THREAD_ID" ]; then + if [ "$MERGED" = "true" ]; then PREFIX="✅"; else PREFIX="🗑️"; fi + NEW_NAME=$(printf '%s PR #%s %s' "$PREFIX" "${{ steps.pr.outputs.pr_number }}" "$PR_TITLE" | cut -c1-90) + CODE=$(curl -s -o /dev/null -w '%{http_code}' -X PATCH \ + "$DISCORD_API/channels/$THREAD_ID" \ + -H "Authorization: Bot $DISCORD_BOT_TOKEN" \ + -H "Content-Type: application/json; charset=utf-8" \ + --data "$(jq -n --arg name "$NEW_NAME" '{name:$name, archived:true, locked:true}')") + [ "$CODE" = "200" ] && echo "스레드 정리됨: $NEW_NAME" || echo "::warning::스레드 정리 실패 (HTTP $CODE)" + fi