diff --git a/.github/scripts/assign-reviewer.cjs b/.github/scripts/assign-reviewer.cjs new file mode 100644 index 0000000..7910dc4 --- /dev/null +++ b/.github/scripts/assign-reviewer.cjs @@ -0,0 +1,58 @@ +module.exports = async ({ github, context, core }) => { + const creator = context.payload.pull_request.user.login; + + // 환경 변수에서 사용자명 읽기 + const { memberOh, memberJi, memberJin, memberHong } = JSON.parse( + process.env.COLLABORATORS, + ); + // 모든 사용자명이 제대로 설정되었는지 확인 + if (!memberOh || !memberJi || !memberJin || !memberHong) { + core.setFailed("필요한 환경 변수가 설정되지 않았습니다."); + return { reviewers: [] }; + } + + // 리뷰어 매핑 정의 + const reviewerMap = { + [memberOh]: [memberJi, memberJin], + [memberJi]: [memberJin, memberHong], + [memberJin]: [memberHong, memberOh], + [memberHong]: [memberOh, memberJi], + }; + + // 해당 사용자의 리뷰어 배열 가져오기 + const reviewers = reviewerMap[creator]; + + // 매핑된 리뷰어가 있으면 리뷰어 요청 및 담당자 지정 + if (reviewers) { + try { + // 리뷰어 요청 + await github.rest.pulls.requestReviewers({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + reviewers: reviewers, + }); + + // 본인을 담당자로 지정 + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + assignees: [creator], + }); + + console.log( + `리뷰어 ${reviewers.join(", ")}와(과) 담당자 ${creator}가 성공적으로 할당되었습니다.`, + ); + + // 리뷰어 정보 반환 + return { reviewers }; + } catch (error) { + core.setFailed(`리뷰어 할당 중 오류가 발생했습니다: ${error.message}`); + return { reviewers: [] }; + } + } else { + console.log(`${creator}에 대한 리뷰어 매핑을 찾을 수 없습니다.`); + return { reviewers: [] }; + } +}; diff --git a/.github/scripts/auto-pr-alarm.cjs b/.github/scripts/auto-pr-alarm.cjs new file mode 100644 index 0000000..5f78fa3 --- /dev/null +++ b/.github/scripts/auto-pr-alarm.cjs @@ -0,0 +1,79 @@ +//* ===================================== +//* PR 알림 스크립트 +//* ===================================== +const { sendDiscordMessage } = require("./modules/discord-service.cjs"); +const { generatePRMessages } = require("./modules/pr-processor.cjs"); +const { safeJsonParse } = require("./modules/utils.cjs"); + +module.exports = async ({ github, context, core }) => { + const owner = context.repo.owner; + const repo = context.repo.repo; + const discordWebhook = process.env.DISCORD_WEBHOOK; + + // Discord 멘션 데이터 파싱 + const discordMentions = safeJsonParse(process.env.DISCORD_MENTION, {}); + + // auto_assigning 작업에서 전달된 할당된 리뷰어 정보 + const assignedReviewersJson = process.env.ASSIGNED_REVIEWERS || "[]"; + const assignedReviewers = safeJsonParse(assignedReviewersJson, []); + + console.log("할당된 리뷰어 정보:", assignedReviewers); + + try { + // PR 이벤트 타입 확인 + const eventType = context.eventName; + const action = context.payload.action; + + // 현재 PR 정보 가져오기 + const pullRequest = context.payload.pull_request; + + if (!pullRequest) { + console.log("처리할 PR 정보 없음"); + return; + } + + // 자동 할당된 리뷰어 정보를 PR 객체에 추가 + // 이미 requested_reviewers가 있을 경우 병합, 없으면 새로 할당 + if (assignedReviewers.length > 0 && !pullRequest.requested_reviewers) { + pullRequest.requested_reviewers = assignedReviewers.map((login) => ({ + login, + })); + } else if (assignedReviewers.length > 0) { + // 이미 존재하는 리뷰어와 새로 할당된 리뷰어 합치기 + const existingLogins = pullRequest.requested_reviewers.map( + (r) => r.login, + ); + const newReviewers = assignedReviewers + .filter((login) => !existingLogins.includes(login)) + .map((login) => ({ login })); + + pullRequest.requested_reviewers = [ + ...pullRequest.requested_reviewers, + ...newReviewers, + ]; + } + + // PR 메시지 생성 + const messages = await generatePRMessages( + github, + owner, + repo, + [pullRequest], + discordMentions, + ); + + // 메시지가 없으면 종료 + if (messages.length === 0) { + console.log("생성된 메시지가 없습니다."); + return; + } + + // Discord로 메시지 전송 + await sendDiscordMessage(discordWebhook, messages, { + headerText: `🔔 PR 알림 (${action}) 🔔`, + }); + } catch (error) { + console.error("PR 리마인더 처리 중 오류 발생:", error.message); + core.setFailed(`PR 리마인더 처리 실패: ${error.message}`); + } +}; diff --git a/.github/scripts/auto-review-alarm.cjs b/.github/scripts/auto-review-alarm.cjs new file mode 100644 index 0000000..ec5ca8d --- /dev/null +++ b/.github/scripts/auto-review-alarm.cjs @@ -0,0 +1,20 @@ +//* ===================================== +//* 디스코드 리뷰 리마인더 +//* ===================================== + +const { ReviewAlarmService } = require("./modules/review-processor.cjs"); +const { safeJsonParse } = require("./modules/utils.cjs"); + +module.exports = async ({ github, context, core }) => { + // 설정 생성 + const config = { + discordWebhook: process.env.DISCORD_WEBHOOK, + discordMentions: safeJsonParse(process.env.DISCORD_MENTION, {}), + }; + + // 서비스 인스턴스 생성 + const reviewAlarmService = new ReviewAlarmService(config); + + // 리뷰 알림 전송 + await reviewAlarmService.sendReviewAlarm(github, context, core); +}; diff --git a/.github/scripts/auto-review-reminder.cjs b/.github/scripts/auto-review-reminder.cjs new file mode 100644 index 0000000..e2a0512 --- /dev/null +++ b/.github/scripts/auto-review-reminder.cjs @@ -0,0 +1,52 @@ +//* ===================================== +//* 디스코드 커스텀 메시지 보내기 - 메인 모듈 +//* ===================================== + +const { sendDiscordMessage } = require("./modules/discord-service.cjs"); +const { fetchOpenPullRequests } = require("./modules/github-service.cjs"); +const { generatePRMessages } = require("./modules/pr-processor.cjs"); +const { isAfterEndDate, safeJsonParse } = require("./modules/utils.cjs"); + +module.exports = async ({ github, context }) => { + // 현재 날짜 확인 + const currentDate = new Date(); + const endDate = new Date("2025-05-12T23:59:59Z"); // 2025년 5월 12일 23:59:59 UTC + + // 현재 날짜가 종료 날짜보다 이후인지 확인 + if (isAfterEndDate(currentDate, endDate)) { + console.log( + "지정된 종료 날짜(2025년 5월 12일)이 지났습니다. 작업을 수행하지 않습니다.", + ); + return; // 함수 종료 + } + + const owner = context.repo.owner; + const repo = context.repo.repo; + const discordWebhook = process.env.DISCORD_WEBHOOK; + + // Discord 멘션 데이터 파싱 + const discordMentions = safeJsonParse(process.env.DISCORD_MENTION, {}); + + try { + // 열린 PR 목록 가져오기 + const openPRs = await fetchOpenPullRequests(github, owner, repo); + if (openPRs.length === 0) return; + + // PR 정보 처리하여 메시지 생성 + const messages = await generatePRMessages( + github, + owner, + repo, + openPRs, + discordMentions, + ); + if (messages.length === 0) return; + + // Discord로 메시지 전송 + await sendDiscordMessage(discordWebhook, messages); + } catch (error) { + console.error("Error processing PR reminders:", error.message); + console.error(error.stack); + throw error; // 워크플로우 실패 상태 반환 + } +}; diff --git a/.github/scripts/modules/constants.cjs b/.github/scripts/modules/constants.cjs new file mode 100644 index 0000000..d0134cc --- /dev/null +++ b/.github/scripts/modules/constants.cjs @@ -0,0 +1,22 @@ +//* ===================================== +//* 상수 정의 모듈 +//* ===================================== + +// GitHub에서 반환하는 리뷰 상태 값 +const GITHUB_REVIEW_STATES = { + APPROVED: "APPROVED", + CHANGES_REQUESTED: "CHANGES_REQUESTED", + COMMENTED: "COMMENTED", +}; + +// 리뷰 상태 표시용 약어 +const STATE_ABBREVIATIONS = { + [GITHUB_REVIEW_STATES.APPROVED]: "Approved", + [GITHUB_REVIEW_STATES.CHANGES_REQUESTED]: "Changes Requested", + [GITHUB_REVIEW_STATES.COMMENTED]: "Commented", +}; + +module.exports = { + GITHUB_REVIEW_STATES, + STATE_ABBREVIATIONS, +}; diff --git a/.github/scripts/modules/discord-service.cjs b/.github/scripts/modules/discord-service.cjs new file mode 100644 index 0000000..6bae988 --- /dev/null +++ b/.github/scripts/modules/discord-service.cjs @@ -0,0 +1,131 @@ +//* ===================================== +//* Discord 서비스 모듈 +//* ===================================== + +/** + * Discord 메시지 전송 서비스 클래스 + */ +class DiscordService { + /** + * 생성자 + * @param {Object} config - 서비스 설정 + * @param {number} [config.timeout=10000] - 요청 타임아웃 (기본값 10초) + * @param {Function} [config.logger=console] - 로깅 객체 (기본값 console) + * @param {string} [config.headerText='🍀 리뷰가 필요한 PR 목록 🍀'] - 메시지 헤더 텍스트 + */ + constructor(config = {}) { + this.timeout = config.timeout || 10000; + this.logger = config.logger || console; + this.headerText = config.headerText || "🍀 리뷰가 필요한 PR 목록 🍀"; + } + + /** + * Discord 웹훅 URL 유효성 검사 + * @param {string} webhookUrl - 검사할 웹훅 URL + * @throws {Error} 웹훅 URL이 없는 경우 에러 발생 + */ + validateWebhookUrl(webhookUrl) { + if (!webhookUrl) { + this.logger.error("Discord 웹훅 URL이 제공되지 않았습니다."); + throw new Error("Discord 웹훅 URL이 필요합니다."); + } + } + + /** + * 메시지 페이로드 생성 + * @param {Array} messages - 전송할 메시지 배열 + * @returns {Object} Discord API 요청 페이로드 + */ + createMessagePayload(messages) { + return { + content: `${this.headerText}\n\n${messages.join("\n\n")}`, + allowed_mentions: { + parse: ["users"], // 멘션 가능한 사용자만 허용 + }, + }; + } + + /** + * 요청 옵션 생성 + * @param {Object} payload - 메시지 페이로드 + * @returns {Object} Fetch API 요청 옵션 + */ + createFetchOptions(payload) { + return { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + timeout: this.timeout, + }; + } + + /** + * 응답 처리 + * @param {Response} response - Fetch API 응답 + * @throws {Error} 요청 실패 시 에러 발생 + */ + async handleResponse(response) { + if (response.ok) { + this.logger.log( + `Discord 메시지 전송 성공! 상태 코드: ${response.status}`, + ); + return; + } + + // 실패 시 상세 로깅 + const responseText = await response.text(); + this.logger.error( + `Discord 메시지 전송 실패. 상태 코드: ${response.status}`, + ); + this.logger.error("응답 내용:", responseText); + throw new Error(`Discord 메시지 전송 실패: ${response.status}`); + } + + /** + * Discord로 메시지 전송 + * @param {string} webhookUrl - Discord 웹훅 URL + * @param {Array} messages - 전송할 메시지 배열 + * @returns {Promise} + */ + async sendMessage(webhookUrl, messages) { + // 웹훅 URL 검증 + this.validateWebhookUrl(webhookUrl); + + // 로깅 + this.logger.log(`Discord에 ${messages.length}개의 PR 정보 전송 중...`); + + try { + // 페이로드 및 요청 옵션 생성 + const payload = this.createMessagePayload(messages); + const fetchOptions = this.createFetchOptions(payload); + + // 메시지 전송 + const response = await fetch(webhookUrl, fetchOptions); + + // 응답 처리 + await this.handleResponse(response); + } catch (error) { + this.logger.error("Discord 메시지 전송 중 오류 발생:", error.message); + throw error; + } + } +} + +/** + * Discord 서비스 인스턴스 생성 및 메시지 전송 함수 + * @param {string} webhookUrl - Discord 웹훅 URL + * @param {Array} messages - 전송할 메시지 배열 + * @param {Object} [options] - 추가 옵션 + * @returns {Promise} + */ +async function sendDiscordMessage(webhookUrl, messages, options = {}) { + const discordService = new DiscordService({ + headerText: options.headerText || "🍀 리뷰가 필요한 PR 목록 🍀", + }); + await discordService.sendMessage(webhookUrl, messages); +} + +module.exports = { + DiscordService, + sendDiscordMessage, +}; diff --git a/.github/scripts/modules/github-service.cjs b/.github/scripts/modules/github-service.cjs new file mode 100644 index 0000000..41e0bf2 --- /dev/null +++ b/.github/scripts/modules/github-service.cjs @@ -0,0 +1,61 @@ +//* ===================================== +//* GitHub API 서비스 모듈 +//* ===================================== + +/** + * 열린 PR 목록을 가져옵니다. + * @param {Object} github - GitHub API 클라이언트 + * @param {string} owner - 저장소 소유자 + * @param {string} repo - 저장소 이름 + * @returns {Array} Draft가 아닌 열린 PR 목록 + */ +async function fetchOpenPullRequests(github, owner, repo) { + console.log(`${owner}/${repo} 저장소의 PR 정보를 가져오는 중...`); + const pullRequests = await github.rest.pulls.list({ + owner, + repo, + state: "open", + }); + + console.log(`총 ${pullRequests.data.length}개의 열린 PR 발견`); + + //* Draft 상태가 아니면서, 생성한 지 오래된 순으로 정렬 + const targetPRs = pullRequests.data + .filter((pr) => !pr.draft) + .sort( + (a, b) => + new Date(a.created_at ?? a.updated_at) - + new Date(b.created_at ?? b.updated_at), + ); + + // Draft가 아닌 PR이 없는 경우에 실행 중지 + if (targetPRs.length === 0) { + console.log("Draft가 아닌 열린 PR이 없습니다. 작업을 수행하지 않습니다."); + return []; + } + + console.log(`Draft가 아닌 PR ${targetPRs.length}개 처리 중...`); + return targetPRs; +} + +/** + * 특정 PR의 리뷰 상태를 가져옵니다. + * @param {Object} github - GitHub API 클라이언트 + * @param {string} owner - 저장소 소유자 + * @param {string} repo - 저장소 이름 + * @param {number} prNumber - PR 번호 + * @returns {Array} 리뷰 목록 + */ +async function getReviews(github, owner, repo, prNumber) { + const reviews = await github.rest.pulls.listReviews({ + owner, + repo, + pull_number: prNumber, + }); + return reviews.data; +} + +module.exports = { + fetchOpenPullRequests, + getReviews, +}; diff --git a/.github/scripts/modules/pr-processor.cjs b/.github/scripts/modules/pr-processor.cjs new file mode 100644 index 0000000..8a6577b --- /dev/null +++ b/.github/scripts/modules/pr-processor.cjs @@ -0,0 +1,216 @@ +//* ===================================== +//* PR 처리 모듈 +//* ===================================== + +const { + GITHUB_REVIEW_STATES, + STATE_ABBREVIATIONS, +} = require("./constants.cjs"); +const { getReviews } = require("./github-service.cjs"); + +/** + * PR 정보를 처리하여 메시지 배열을 생성합니다. + */ +async function generatePRMessages( + github, + owner, + repo, + pullRequests, + discordMentions, +) { + // 레포지토리 협력자 여부 확인 + let hasCollaborators = false; + try { + const { data } = await github.rest.repos.listCollaborators({ + owner, + repo, + per_page: 1, + }); + hasCollaborators = data.length > 0; + console.log(`${owner}/${repo} 레포지토리 협력자 여부:`, hasCollaborators); + } catch (error) { + console.warn(`협력자 정보 가져오기 실패:`, error.message); + hasCollaborators = true; // 오류 시 안전하게 협력자가 있다고 가정 + } + + // 각 PR에 대해 메시지 생성 + return Promise.all( + pullRequests.map(async (pr) => { + console.log(`PR #${pr.number} "${pr.title}" 처리 중`); + const reviews = await getReviews(github, owner, repo, pr.number); + const requestedReviewers = + pr.requested_reviewers?.map(({ login }) => login) || []; + + const reviewInfo = analyzeReviewStatuses( + pr, + reviews, + requestedReviewers, + discordMentions, + hasCollaborators, + ); + + return generatePRMessage( + pr, + reviewInfo, + discordMentions, + hasCollaborators, + ); + }), + ); +} + +/** + * PR의 리뷰 상태를 분석합니다. + */ +function analyzeReviewStatuses( + pr, + reviews, + requestedReviewers, + discordMentions, + hasCollaborators, +) { + // 리뷰어별 최신 상태 추출 + const reviewStates = new Map(); + reviews.forEach((review) => { + const { + user: { login }, + state, + } = review; + if (login !== pr.user.login) reviewStates.set(login, state); + }); + + // 승인 및 부정적 리뷰 수 계산 + const approvedReviewCount = [...reviewStates.values()].filter( + (state) => state === GITHUB_REVIEW_STATES.APPROVED, + ).length; + + const hasNegativeReviews = [...reviewStates.values()].some((state) => + ["CHANGES_REQUESTED", "DISMISSED"].includes(state), + ); + + // 리뷰어 상태 메시지 생성 + const reviewStatuses = [...reviewStates].map(([reviewer, state]) => { + const discordInfo = discordMentions[reviewer] || { + id: reviewer, + displayName: reviewer, + }; + const reviewState = STATE_ABBREVIATIONS[state] || state.toLowerCase(); + + return state === GITHUB_REVIEW_STATES.APPROVED + ? `${discordInfo.displayName}(${reviewState})` + : `<@${discordInfo.id}>(${reviewState})`; + }); + + // 리뷰 미시작 리뷰어 처리 + const notStartedReviewers = requestedReviewers.filter( + (reviewer) => !reviewStates.has(reviewer) && reviewer !== pr.user.login, + ); + + const notStartedMentions = notStartedReviewers.map((reviewer) => { + const discordInfo = discordMentions[reviewer] || { id: reviewer }; + return `<@${discordInfo.id}>(X)`; + }); + + // 모든 리뷰 상태 메시지 통합 + const reviewStatusMessage = [...reviewStatuses, ...notStartedMentions]; + + // 승인 완료 상태 결정 + const hasNoRequestedReviewers = requestedReviewers.length === 0; + const isNotHasPendingReviews = notStartedReviewers.length === 0; + const isAllReviewersApproved = + hasNoRequestedReviewers || + requestedReviewers.every( + (reviewer) => + reviewStates.get(reviewer) === GITHUB_REVIEW_STATES.APPROVED, + ); + const isApprovalComplete = !hasCollaborators || approvedReviewCount > 0; + + // 디버깅 로그 + console.log(`PR #${pr.number} 승인 상태:`, { + approvedCount: approvedReviewCount, + isAllApproved: isAllReviewersApproved, + noPending: isNotHasPendingReviews, + hasNegative: hasNegativeReviews, + }); + + return { + reviewStatusMessage, + isAllReviewersApproved, + isNotHasPendingReviews, + hasNoRequestedReviewers, + approvedReviewCount, + isApprovalComplete, + hasNegativeReviews, + }; +} + +/** + * PR 정보와 리뷰 상태를 기반으로 메시지를 생성합니다. + */ +function generatePRMessage(pr, reviewInfo, discordMentions, hasCollaborators) { + const { + reviewStatusMessage, + isAllReviewersApproved, + isNotHasPendingReviews, + hasNoRequestedReviewers, + approvedReviewCount, + isApprovalComplete, + hasNegativeReviews, + } = reviewInfo; + + // PR 작성자 멘션 + const authorMention = discordMentions[pr.user.login]?.id || pr.user.login; + + // 머지 가능 여부 (승인 완료 + 보류 없음 + 부정적 리뷰 없음) + const canMerge = + isApprovalComplete && isNotHasPendingReviews && !hasNegativeReviews; + + // 머지 가능한 경우 + if (canMerge) { + let approvalMessage; + + if (!hasCollaborators) { + approvalMessage = + "모든 리뷰어의 승인 완료! 코멘트를 확인 후 머지해 주세요 🚀"; + } else if (approvedReviewCount > 0) { + approvalMessage = isAllReviewersApproved + ? "모든 리뷰어의 승인 완료! 코멘트를 확인 후 머지해 주세요 🚀" + : "필요한 승인 수를 만족했습니다! 코멘트를 확인 후 머지해 주세요 🚀"; + } else if (hasNoRequestedReviewers) { + approvalMessage = + "할당된 리뷰어가 없지만, 적어도 하나의 승인이 필요합니다."; + } else { + approvalMessage = "머지 준비가 완료되었습니다."; + } + + const showReviewers = hasCollaborators && reviewStatusMessage.length > 0; + const reviewerList = showReviewers + ? `리뷰어: ${reviewStatusMessage.join(", ")}\n` + : ""; + + return `[[PR] ${pr.title}](<${pr.html_url}>)\n${reviewerList}<@${authorMention}>, ${approvalMessage}`; + } + // 부정적 리뷰가 있는 경우 + else if (hasNegativeReviews) { + const showReviewers = reviewStatusMessage.length > 0; + const reviewerList = showReviewers + ? `리뷰어: ${reviewStatusMessage.join(", ")}\n` + : ""; + + return `[[PR] ${pr.title}](<${pr.html_url}>)\n${reviewerList}<@${authorMention}>, 머지 준비가 완료되지 않았습니다. PR을 확인해 주세요.`; + } + + // 일반 메시지 + const showReviewers = reviewStatusMessage.length > 0; + const reviewerMsg = showReviewers + ? `리뷰어: ${reviewStatusMessage.join(", ")}` + : "리뷰어가 없습니다."; + + return `[[PR] ${pr.title}](<${pr.html_url}>)\n${reviewerMsg}`; +} + +module.exports = { + generatePRMessages, + analyzeReviewStatuses, + generatePRMessage, +}; diff --git a/.github/scripts/modules/review-processor.cjs b/.github/scripts/modules/review-processor.cjs new file mode 100644 index 0000000..a4c7a63 --- /dev/null +++ b/.github/scripts/modules/review-processor.cjs @@ -0,0 +1,231 @@ +//* ===================================== +//* 리뷰 리마인더 모듈 +//* ===================================== +const { sendDiscordMessage } = require("./discord-service.cjs"); +const { getReviews } = require("./github-service.cjs"); + +/** + * 리뷰 상태를 매핑하는 함수 + * @param {string} state - GitHub 리뷰 상태 + * @returns {string} 이모지와 함께하는 상태 메시지 + */ +function mapReviewState(state) { + const upperCaseState = state.toUpperCase(); + const stateMap = { + APPROVED: "승인 ✅", + CHANGES_REQUESTED: "변경 요청 ⚠️", + COMMENTED: "코멘트 💬", + }; + return stateMap[upperCaseState] || "리뷰 상태 알 수 없음 ❓"; +} + +/** + * 리뷰어 정보를 가져오는 클래스 + */ +class ReviewerInfoManager { + /** + * 생성자 + * @param {Object} discordMentions - 디스코드 멘션 매핑 객체 + */ + constructor(discordMentions) { + this.discordMentions = discordMentions; + } + + /** + * 리뷰어의 디스코드 정보 가져오기 + * @param {string} reviewerLogin - 리뷰어 깃허브 로그인 이름 + * @returns {Object} 디스코드 정보 + */ + getDiscordInfo(reviewerLogin) { + return ( + this.discordMentions[reviewerLogin] || { + id: reviewerLogin, + displayName: reviewerLogin, + } + ); + } +} + +/** + * PR 리뷰 상태 분석 클래스 + */ +class PRReviewAnalyzer { + /** + * 생성자 + * @param {Object} github - GitHub API 클라이언트 + * @param {string} owner - 레포지토리 소유자 + * @param {string} repo - 레포지토리 이름 + */ + constructor(github, owner, repo) { + this.github = github; + this.owner = owner; + this.repo = repo; + } + + /** + * PR의 리뷰 상태 분석 + * @param {Object} pullRequest - PR 정보 + * @returns {Promise} 리뷰 상태 분석 결과 + */ + async analyzeReviewStatus(pullRequest) { + // PR의 모든 리뷰 가져오기 + const reviews = await getReviews( + this.github, + this.owner, + this.repo, + pullRequest.number, + ); + + // 리뷰 상태 확인 + const reviewStates = new Map(); + reviews.forEach((rev) => { + const { + user: { login }, + state, + } = rev; + if (login !== pullRequest.user.login) { + reviewStates.set(login, state); + } + }); + + // 요청된 리뷰어 찾기 + const requestedReviewers = + pullRequest.requested_reviewers?.map((r) => r.login) || []; + + // 아직 리뷰하지 않은 리뷰어 찾기 + const pendingReviewers = requestedReviewers.filter( + (reviewer) => + !reviewStates.has(reviewer) || + ["COMMENTED", "DISMISSED"].includes(reviewStates.get(reviewer)), + ); + + return { + reviewStates, + requestedReviewers, + pendingReviewers, + }; + } +} + +/** + * 디스코드 메시지 생성 클래스 + */ +class DiscordMessageBuilder { + /** + * 생성자 + * @param {ReviewerInfoManager} reviewerInfoManager - 리뷰어 정보 관리자 + */ + constructor(reviewerInfoManager) { + this.reviewerInfoManager = reviewerInfoManager; + } + + /** + * 디스코드 메시지 생성 + * @param {Object} pullRequest - PR 정보 + * @param {Object} review - 리뷰 정보 + * @param {Object} reviewAnalysis - 리뷰 분석 결과 + * @returns {string} 디스코드 메시지 + */ + buildMessage(pullRequest, review, reviewAnalysis) { + const reviewerLogin = review.user.login; + const reviewerDiscord = + this.reviewerInfoManager.getDiscordInfo(reviewerLogin); + + // PR 작성자의 디스코드 정보 가져오기 (추가된 부분) + const authorLogin = pullRequest.user.login; + const authorDiscord = this.reviewerInfoManager.getDiscordInfo(authorLogin); + + // 리뷰 상태 메시지 + const reviewMessage = mapReviewState(review.state); + + // 디스코드 메시지 포맷팅 + let message = `[[PR] ${pullRequest.title}](<${pullRequest.html_url}>) +PR 작성자: <@${authorDiscord.id}> +리뷰어: ${reviewerDiscord.displayName} +리뷰 상태: ${reviewMessage} + +리뷰 내용: +\`\`\` +${review.body || "상세 리뷰 내용 없음"} +\`\`\``; + + // 보류 중인 리뷰어 멘션 생성 + const pendingReviewerMentions = reviewAnalysis.pendingReviewers + .map((reviewer) => { + const discordInfo = this.reviewerInfoManager.getDiscordInfo(reviewer); + return `<@${discordInfo.id}>`; + }) + .join(" "); + + // 보류 중인 리뷰어가 있다면 멘션 추가 + if (pendingReviewerMentions) { + message += `\n⏳ 아직 리뷰하지 않은 리뷰어들: ${pendingReviewerMentions}\n리뷰를 완료해 주세요! 🔍`; + } + + return message; + } +} + +/** + * 리뷰 알림 서비스 + */ +class ReviewAlarmService { + /** + * 생성자 + * @param {Object} config - 서비스 설정 + * @param {string} config.discordWebhook - 디스코드 웹훅 URL + * @param {Object} config.discordMentions - 디스코드 멘션 매핑 + */ + constructor(config) { + this.discordWebhook = config.discordWebhook; + this.reviewerInfoManager = new ReviewerInfoManager(config.discordMentions); + } + + /** + * 리뷰 알림 처리 + * @param {Object} github - GitHub API 클라이언트 + * @param {Object} context - GitHub 액션 컨텍스트 + * @param {Object} core - GitHub 액션 코어 + */ + async sendReviewAlarm(github, context, core) { + try { + const owner = context.repo.owner; + const repo = context.repo.repo; + + // 현재 리뷰 이벤트 정보 추출 + const { pull_request, review } = context.payload; + + if (!pull_request || !review) { + console.log("풀 리퀘스트 또는 리뷰 정보 없음"); + return; + } + + // PR 리뷰 분석 + const reviewAnalyzer = new PRReviewAnalyzer(github, owner, repo); + const reviewAnalysis = + await reviewAnalyzer.analyzeReviewStatus(pull_request); + + // 메시지 생성 + const messageBuilder = new DiscordMessageBuilder( + this.reviewerInfoManager, + ); + const message = messageBuilder.buildMessage( + pull_request, + review, + reviewAnalysis, + ); + + // Discord로 메시지 전송 + await sendDiscordMessage(this.discordWebhook, [message], { + headerText: "⭐️ 리뷰 알림 ⭐️", + }); + } catch (error) { + console.error("리뷰 알림 처리 중 오류 발생:", error.message); + core.setFailed(`리뷰 알림 처리 실패: ${error.message}`); + } + } +} + +module.exports = { + ReviewAlarmService, +}; diff --git a/.github/scripts/modules/utils.cjs b/.github/scripts/modules/utils.cjs new file mode 100644 index 0000000..cdf9599 --- /dev/null +++ b/.github/scripts/modules/utils.cjs @@ -0,0 +1,35 @@ +//* ===================================== +//* 유틸리티 함수 모듈 +//* ===================================== + +/** + * 날짜가 지정된 종료일 이후인지 확인합니다. + * @param {Date} currentDate - 현재 날짜 + * @param {Date} endDate - 종료 날짜 + * @returns {boolean} 현재 날짜가 종료 날짜 이후인지 여부 + */ +function isAfterEndDate(currentDate, endDate) { + return currentDate > endDate; +} + +/** + * 안전하게 JSON 문자열을 파싱합니다. + * @param {string} jsonString - JSON 문자열 + * @param {Object} defaultValue - 파싱 실패 시 반환할 기본값 + * @returns {Object} 파싱된 객체 또는 기본값 + */ +function safeJsonParse(jsonString, defaultValue = {}) { + if (!jsonString) return defaultValue; + + try { + return JSON.parse(jsonString); + } catch (error) { + console.error("JSON 파싱 실패:", error.message); + return defaultValue; + } +} + +module.exports = { + isAfterEndDate, + safeJsonParse, +}; diff --git a/.github/workflows/auto-pr-alarm.yml b/.github/workflows/auto-pr-alarm.yml new file mode 100644 index 0000000..b8dba58 --- /dev/null +++ b/.github/workflows/auto-pr-alarm.yml @@ -0,0 +1,75 @@ +name: Auto Send PR Discord Alarm + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + branches: ["*"] + workflow_dispatch: + +jobs: + auto_assigning: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + outputs: + assigned_reviewers: ${{ steps.assign-reviewers.outputs.reviewers }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run assign reviewers script + id: assign-reviewers + uses: actions/github-script@v7 + if: github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'ready_for_review') + env: + # { + # "자바스크립트_코드_변수": "github_사용자_이름" + # } + COLLABORATORS: ${{ secrets.COLLABORATORS }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const script = require('.github/scripts/assign-reviewer.cjs'); + const reviewersInfo = await script({ github, context, core }); + + // 다음 job에서 사용할 수 있도록 output 설정 + if (reviewersInfo?.reviewers) { + core.setOutput('reviewers', JSON.stringify(reviewersInfo.reviewers)); + } else { + core.setOutput('reviewers', '[]'); + } + + send-pr-alarm: + runs-on: ubuntu-latest + needs: auto_assigning + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Send PR Alarm to Discord + uses: actions/github-script@v7 + env: + # { + # "github_사용자_이름": { + # "id": "디스코드 사용자 ID (개발자용)", + # "displayName": "메시지에서 보여질 이름" + # } + # } + DISCORD_MENTION: ${{ secrets.DISCORD_MENTION }} + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + ASSIGNED_REVIEWERS: ${{ needs.auto_assigning.outputs.assigned_reviewers }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const script = require('.github/scripts/auto-pr-alarm.cjs'); + await script({ github, context, core }); diff --git a/.github/workflows/auto-pr-reminder.yml b/.github/workflows/auto-pr-reminder.yml new file mode 100644 index 0000000..f3a458a --- /dev/null +++ b/.github/workflows/auto-pr-reminder.yml @@ -0,0 +1,31 @@ +name: Sechduling Send Discord Reminder + +on: + schedule: + - cron: "30 1 * * *" # 매일 오전 10시 30분 (KST) + - cron: "0 8 * * *" # 매일 오후 5시 (KST) + workflow_dispatch: + +jobs: + send-pr-reminder: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + actions: read + checks: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Send Reminder to Discord + uses: actions/github-script@v7 + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + DISCORD_MENTION: ${{ secrets.DISCORD_MENTION }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const script = require('.github/scripts/auto-review-reminder.cjs'); + await script({ github, context, core }); diff --git a/.github/workflows/auto-review-alarm.yml b/.github/workflows/auto-review-alarm.yml new file mode 100644 index 0000000..e209d15 --- /dev/null +++ b/.github/workflows/auto-review-alarm.yml @@ -0,0 +1,27 @@ +name: Auto Send Discord Review Reminder + +on: + pull_request_review: + types: [submitted] + workflow_dispatch: + +jobs: + send-review-alarm: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Send Review Alarm to Discord + uses: actions/github-script@v7 + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + DISCORD_MENTION: ${{ secrets.DISCORD_MENTION }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const script = require('.github/scripts/auto-review-alarm.cjs'); + await script({ github, context, core }); diff --git a/vite.config.ts b/vite.config.ts index 4f62a1c..2013342 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,8 +13,6 @@ const aliases = Object.entries(tsPathsConfig.compilerOptions.paths).map( }), ); -console.log(aliases); - //* https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()],