diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 335d22d..e03a2fd 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -1,119 +1,118 @@ -name: Auto Label - -on: - issues: - types: [opened, edited] - pull_request_target: - types: [opened, edited, reopened, synchronize] - -permissions: - issues: write - -jobs: - label: - runs-on: ubuntu-latest - steps: - - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b - with: - script: | - const isIssue = context.eventName === 'issues'; - const item = isIssue - ? context.payload.issue - : context.payload.pull_request; - - const title = item.title.toLowerCase(); - const body = (item.body || '').toLowerCase(); - const author = item.user.login; - - const labels = new Set(); - - const titleRules = [ - { - label: '✨ feat', - patterns: [/\[feat\]/, /(?:^|[^a-z0-9])feat:/] - }, - { - label: 'πŸ› οΈ fix', - patterns: [/\[fix\]/, /(?:^|[^a-z0-9])fix:/] - }, - { - label: '🚨 hotfix', - patterns: [/\[hotfix\]/, /(?:^|[^a-z0-9])hotfix:/] - }, - { - label: 'πŸ“ƒ docs', - patterns: [/\[docs\]/, /(?:^|[^a-z0-9])docs:/] - }, - { - label: 'πŸ” refactor', - patterns: [/\[refactor\]/, /(?:^|[^a-z0-9])refactor:/] - }, - { - label: 'πŸ“ chore', - patterns: [/\[chore\]/, /(?:^|[^a-z0-9])chore:/] - }, - { - label: 'πŸ§ͺ test', - patterns: [/\[test\]/, /(?:^|[^a-z0-9])test:/] - }, - { - label: '✏️ style', - patterns: [/\[style\]/, /(?:^|[^a-z0-9])style:/] - }, - { - label: 'βœ’οΈ comment', - patterns: [/\[comment\]/, /(?:^|[^a-z0-9])comment:/] - }, - { - label: 'πŸ“„ rename', - patterns: [/\[rename\]/, /(?:^|[^a-z0-9])rename:/] - }, - { - label: '♻️ remove', - patterns: [/\[remove\]/, /(?:^|[^a-z0-9])remove:/] - } - ]; - - for (const rule of titleRules) { - if (rule.patterns.some(pattern => pattern.test(title))) { - labels.add(rule.label); - } - } - - const authorLabels = { - 'Jy000n': '🌸 자윀', - 'aneykrap': '🌡 μ˜ˆλ‚˜', - 'laura-jung': 'πŸ€οΈ μœ€μ•„' - }; - - const managedLabels = new Set([ - ...titleRules.map(rule => rule.label), - ...Object.values(authorLabels) - ]); - - if (authorLabels[author]) { - labels.add(authorLabels[author]); - } - - const currentLabels = (item.labels || []).map(label => label.name || label); - const labelsToRemove = currentLabels.filter(label => managedLabels.has(label)); - - await Promise.all(labelsToRemove.map(label => github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: item.number, - name: label - }).catch(error => { - if (error.status !== 404) { - throw error; - } - }))); - - if (labels.size > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: item.number, - labels: [...labels] - }); - } +#name: Auto Label +# +#on: +# issues: +# types: [opened, edited] +# pull_request_target: +# types: [opened, edited, reopened, synchronize] +# +#permissions: +# issues: write +# +#jobs: +# label: +# runs-on: ubuntu-latest +# steps: +# - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b +# with: +# script: | +# const isIssue = context.eventName === 'issues'; +# const item = isIssue +# ? context.payload.issue +# : context.payload.pull_request; +# +# const title = item.title.toLowerCase(); +# const body = (item.body || '').toLowerCase(); +# const author = item.user.login; +# +# const labels = new Set(); +# +# const titleRules = [ +# { +# label: '✨ feat', +# patterns: [/\[feat\]/, /(?:^|[^a-z0-9])feat:/] +# }, +# { +# label: 'πŸ› οΈ fix', +# patterns: [/\[fix\]/, /(?:^|[^a-z0-9])fix:/] +# }, +# { +# label: '🚨 hotfix', +# patterns: [/\[hotfix\]/, /(?:^|[^a-z0-9])hotfix:/] +# }, +# { +# label: 'πŸ“ƒ docs', +# patterns: [/\[docs\]/, /(?:^|[^a-z0-9])docs:/] +# }, +# { +# label: 'πŸ” refactor', +# patterns: [/\[refactor\]/, /(?:^|[^a-z0-9])refactor:/] +# }, +# { +# label: 'πŸ“ chore', +# patterns: [/\[chore\]/, /(?:^|[^a-z0-9])chore:/] +# }, +# { +# label: 'πŸ§ͺ test', +# patterns: [/\[test\]/, /(?:^|[^a-z0-9])test:/] +# }, +# { +# label: '✏️ style', +# patterns: [/\[style\]/, /(?:^|[^a-z0-9])style:/] +# }, +# { +# label: 'βœ’οΈ comment', +# patterns: [/\[comment\]/, /(?:^|[^a-z0-9])comment:/] +# }, +# { +# label: 'πŸ“„ rename', +# patterns: [/\[rename\]/, /(?:^|[^a-z0-9])rename:/] +# }, +# { +# label: '♻️ remove', +# patterns: [/\[remove\]/, /(?:^|[^a-z0-9])remove:/] +# } +# ]; +# +# for (const rule of titleRules) { +# if (rule.patterns.some(pattern => pattern.test(title))) { +# labels.add(rule.label); +# } +# } +# +# const authorLabels = { +# 'Jy000n': '🌸 자윀', +# 'aneykrap': '🌡 μ˜ˆλ‚˜', +# 'laura-jung': 'πŸ€οΈ μœ€μ•„' +# }; +# +# const managedLabels = new Set([ +# ...titleRules.map(rule => rule.label), +# ...Object.values(authorLabels) +# ]); +# +# if (authorLabels[author]) { +# labels.add(authorLabels[author]); +# } +# +# const currentLabels = (item.labels || []).map(label => label.name || label); +# const labelsToRemove = currentLabels.filter(label => managedLabels.has(label)); +# +# await Promise.all(labelsToRemove.map(label => github.rest.issues.removeLabel({ +# owner: context.repo.owner, +# repo: context.repo.repo, +# issue_number: item.number, +# name: label +# }).catch(error => { +# if (error.status !== 404) { +# throw error; +# } +# }))); +# +# if (labels.size > 0) { +# await github.rest.issues.addLabels({ +# owner: context.repo.owner, +# repo: context.repo.repo, +# issue_number: item.number, +# labels: [...labels] +# }); \ No newline at end of file diff --git a/.github/workflows/pr-approval-notify.yml b/.github/workflows/pr-approval-notify.yml new file mode 100644 index 0000000..1789f5a --- /dev/null +++ b/.github/workflows/pr-approval-notify.yml @@ -0,0 +1,154 @@ +name: PR 승인 μ™„λ£Œ Slack μ•Œλ¦Ό +on: + pull_request_review: + types: [submitted, dismissed] + pull_request: + types: [synchronize,ready_for_review] + +permissions: + pull-requests: write + issues: write + +env: + REQUIRED_APPROVALS: "2" + NOTIFIED_LABEL: "slack-approval-notified" + +concurrency: + group: pr-approval-notify-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + notify: + if: github.event.pull_request.state == 'open' && github.event.pull_request.draft == false + runs-on: ubuntu-latest + + steps: + - name: 승인 μƒνƒœ 확인 및 Slack μ•Œλ¦Ό 전솑 + uses: actions/github-script@v8 + env: + SLACK_PR_WEBHOOK_URL: ${{ secrets.SLACK_PR_WEBHOOK_URL }} + SLACK_USER_MAP: ${{ secrets.SLACK_USER_MAP }} + with: + script: | + const { owner, repo } = context.repo; + const pr = context.payload.pull_request; + + const requiredApprovals = Number(process.env.REQUIRED_APPROVALS); + const markerLabel = process.env.NOTIFIED_LABEL; + const webhookUrl = process.env.SLACK_PR_WEBHOOK_URL; + const rawUserMap = process.env.SLACK_USER_MAP; + + if (!webhookUrl) { + throw new Error("Repository Secret 'SLACK_PR_WEBHOOK_URL'이 μ—†μŠ΅λ‹ˆλ‹€."); + } + + if (!rawUserMap) { + throw new Error("Repository Secret 'SLACK_USER_MAP'이 μ—†μŠ΅λ‹ˆλ‹€."); + } + + const reviews = await github.paginate( + github.rest.pulls.listReviews, + { + owner, + repo, + pull_number: pr.number, + per_page: 100 + } + ); + + const latestOpinionByReviewer = new Map(); + + for (const review of reviews) { + const reviewerLogin = review.user?.login; + + if (!reviewerLogin || reviewerLogin === pr.user.login) { + continue; + } + + if ( + ["APPROVED", "CHANGES_REQUESTED", "DISMISSED"] + .includes(review.state) + ) { + latestOpinionByReviewer.set(reviewerLogin, review.state); + } + } + + const approvers = [...latestOpinionByReviewer.entries()] + .filter(([, state]) => state === "APPROVED") + .map(([login]) => login); + + const labels = await github.paginate( + github.rest.issues.listLabelsOnIssue, + { + owner, + repo, + issue_number: pr.number, + per_page: 100 + } + ); + + const alreadyNotified = labels.some( + (label) => label.name === markerLabel + ); + + const approvalsCompleted = + approvers.length >= requiredApprovals; + + if (!approvalsCompleted) { + if (alreadyNotified) { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pr.number, + name: markerLabel + }); + } + + return; + } + + if (alreadyNotified) { + return; + } + + let slackUserMap; + + try { + slackUserMap = JSON.parse(rawUserMap); + } catch { + throw new Error( + "SLACK_USER_MAP은 μ˜¬λ°”λ₯Έ JSON ν˜•μ‹μ΄μ–΄μ•Ό ν•©λ‹ˆλ‹€." + ); + } + + const slackMemberId = slackUserMap[pr.user.login]; + + if (!slackMemberId) { + throw new Error( + `SLACK_USER_MAP에 '${pr.user.login}'의 Slack 멀버 IDκ°€ μ—†μŠ΅λ‹ˆλ‹€.` + ); + } + + const text = [ + `<@${slackMemberId}> 리뷰 승인 μ™„λ£Œ 🧞`, + `<${pr.html_url}|PR λ³΄λŸ¬κ°€κΈ°>` + ].join("\n"); + + const slackResponse = await fetch(webhookUrl, { + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8" + }, + body: JSON.stringify({ text }) + }); + + if (!slackResponse.ok) { + throw new Error("Slack μ•Œλ¦Ό 전솑에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."); + } + + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pr.number, + labels: [markerLabel] + }); diff --git a/.github/workflows/pr-merged-notify.yml b/.github/workflows/pr-merged-notify.yml new file mode 100644 index 0000000..606d164 --- /dev/null +++ b/.github/workflows/pr-merged-notify.yml @@ -0,0 +1,42 @@ +name: PR λ¨Έμ§€ μ™„λ£Œ Slack μ•Œλ¦Ό + +on: + pull_request: + types: [closed] + +jobs: + notify: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + - name: Slack λ¨Έμ§€ μ™„λ£Œ μ•Œλ¦Ό 전솑 + uses: actions/github-script@v8 + env: + SLACK_PR_WEBHOOK_URL: ${{ secrets.SLACK_PR_WEBHOOK_URL }} + with: + script: | + const pr = context.payload.pull_request; + const webhookUrl = process.env.SLACK_PR_WEBHOOK_URL; + + if (!webhookUrl) { + throw new Error("Repository Secret 'SLACK_PR_WEBHOOK_URL'이 μ—†μŠ΅λ‹ˆλ‹€."); + } + + const text = [ + " PR이 λ¨Έμ§€λ˜μ—ˆμ–΄μš”!", + `<${pr.html_url}|PR λ³΄λŸ¬κ°€κΈ°>` + ].join("\n"); + + const slackResponse = await fetch(webhookUrl, { + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8" + }, + body: JSON.stringify({ text }), + signal: AbortSignal.timeout(10_000) + }); + + if (!slackResponse.ok) { + throw new Error("Slack μ•Œλ¦Ό 전솑에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."); + }