기타

Github 디스코드에 웹훅 알림 추가하기 (Github Action)

Atriel 2025. 5. 17. 21:33

최근 팀작업을 하면서 깃허브에 discussion이나 issue가 새로올라오거나 댓글이달리거나
PR요청이오고 내 PR에 코멘트가달려도 깃허브알림을 볼때까지 모르고있거나 하는 일이 잦아서

알림기능이 있는 디스코드에 웹훅으로 연결해서 알림을 받고자 시작하게되었다

하지만 문제는 나는 내 팀 repo에서 admin이 아니라 contributor라는점
github의 웹훅기능은 admin이여야 가능하다!

물론 admin권한을달라해도 되지만 
그러면 내가 올리는 PR들은 PR규칙에 걸리지않게되고
컨벤션도 자동으로 관리가 안되게 되서 (어드민 권한일시에만)

Github Action을 통해서 구현을 하기로 했다
주기적으로 체킹하고~ 디스코드 웹훅에 알림을 주는식이다

먼저 Github Action용 개인 repo를 하나 만들었다
이름은 github-pr-notifier로 하고
개인정보가 조금 들어가니 그냥 private로 만들었다

이 repo에 이제 2가지 파일이 필요한데
GitHub Actions 워크 플로우 파일과
PR및 디스커션 이슈 체크용 스크립트 파일 (node.js를 사용) 이렇게 두가지가 필요하다

먼저  본인 repo에 action 탭에서


set up a workflow yourself를 클릭하고

아래내용을 붙여서 워크플로우를 생성해준다

name: GitHub Notifications

on:
  schedule:
    - cron: '*/30 * * * *'  # 30분마다 실행
  workflow_dispatch:  # 수동으로도 실행 가능

jobs:
  send-notifications:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v3
      
      - name: List repository contents
        run: ls -la
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
      
      - name: Install dependencies
        run: npm install axios
      
      - name: Run notification script
        env:
          GITHUB_TOKEN: ${{ secrets.PERSONAL_GITHUB_TOKEN }}
          DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
          GITHUB_USERNAME: ${{ secrets.MY_GITHUB_USERNAME }}
          REPOS_TO_MONITOR: ${{ secrets.REPOS_TO_MONITOR }}
        run: node repo-checker.js

더 자주 실행해도 되지만 계산해본결과
30분으로 해야 무료계정 월 2,000분의 실행시간에 걸리지않아서 
30분으로 진행했다. 30분마다 새로운 알림이있나 체크하는것

적절하게 이름을 정해주고 커밋올려주면된다

두번째 파일은
repo 저장소에가서 Create new file을 해준뒤
js파일을 만들어주자
방금 workflow를 만든 폴더가 아닌 루트 폴더에 만들어줘야한다.

나는 이름을
repo-checker.js로 정해줬다

만약 다른이름으로하고싶다면 다른이름으로 한후 방금 생성한파일 (action 워크플로우 yml파일)
맨마지막줄에 run에서 파일명을 변경해주면된다

const axios = require('axios');

// 환경 변수에서 설정 가져오기
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL;
const GITHUB_USERNAME = process.env.GITHUB_USERNAME;
// JSON 문자열로 저장된 저장소 목록을 파싱
const REPOS_TO_MONITOR = JSON.parse(process.env.REPOS_TO_MONITOR || '[]');

// 마지막 확인 시간 (State 유지가 안 되므로 항상 최근 2시간만 확인)
const lastCheckedTime = new Date();
lastCheckedTime.setHours(lastCheckedTime.getHours() - 2);

console.log('GitHub 알림 체커 시작됨');
console.log('모니터링 중인 저장소:', REPOS_TO_MONITOR);
console.log('사용자:', GITHUB_USERNAME);
console.log('마지막 확인 시간:', lastCheckedTime.toISOString());

// GitHub API 호출 설정
const githubAPI = axios.create({
  baseURL: 'https://api.github.com',
  headers: {
    Authorization: `token ${GITHUB_TOKEN}`,
    Accept: 'application/vnd.github.v3+json'
  }
});

// GitHub API에 사용할 추가 헤더 (Discussions API용)
const discussionsHeader = {
  headers: {
    Authorization: `token ${GITHUB_TOKEN}`,
    Accept: 'application/vnd.github.v3+json,application/vnd.github.squirrel-girl-preview'
  }
};

// 새 PR 리뷰 요청 확인
async function checkForReviewRequests() {
  console.log('PR 리뷰 요청 확인 중...');
  
  for (const repoFullName of REPOS_TO_MONITOR) {
    const [owner, repo] = repoFullName.split('/');
    
    try {
      console.log(`저장소 확인 중: ${owner}/${repo}`);
      
      // 열린 PR 목록 가져오기
      const response = await githubAPI.get(`/repos/${owner}/${repo}/pulls`, {
        params: { state: 'open' }
      });
      
      console.log(`${response.data.length}개의 열린 PR 발견`);
      
      // 각 PR에 대해 리뷰 요청 확인
      for (const pr of response.data) {
        // PR 세부 정보 가져오기 (리뷰 요청 포함)
        const prDetail = await githubAPI.get(`/repos/${owner}/${repo}/pulls/${pr.number}`);
        
        // 나에게 리뷰 요청이 있는지 확인
        const requestedReviewers = prDetail.data.requested_reviewers || [];
        const isRequestedForMe = requestedReviewers.some(
          reviewer => reviewer.login.toLowerCase() === GITHUB_USERNAME.toLowerCase()
        );
        
        // PR 업데이트 시간이 마지막 확인 이후인지 확인
        const prUpdatedAt = new Date(pr.updated_at);
        
        if (isRequestedForMe && prUpdatedAt > lastCheckedTime) {
          console.log(`새 리뷰 요청 발견: ${pr.title}`);
          
          // Discord로 알림 보내기
          await sendDiscordNotification({
            title: `🔍 새 PR 리뷰 요청이 왔습니다`,
            description: `PR: ${pr.title}`,
            url: pr.html_url,
            author: pr.user.login,
            repo: `${owner}/${repo}`
          });
        }
      }
    } catch (error) {
      console.error(`Error checking ${owner}/${repo} PRs:`, error.message);
    }
  }
}

// 내 PR에 리뷰가 달렸는지 확인
async function checkForNewReviews() {
  console.log('PR 리뷰 확인 중...');
  
  for (const repoFullName of REPOS_TO_MONITOR) {
    const [owner, repo] = repoFullName.split('/');
    
    try {
      // 내가 작성한 열린 PR 가져오기
      const response = await githubAPI.get(`/repos/${owner}/${repo}/pulls`, {
        params: { state: 'open', creator: GITHUB_USERNAME }
      });
      
      console.log(`${response.data.length}개의 내가 작성한 PR 발견`);
      
      // 각 PR에 대해 새 리뷰 확인
      for (const pr of response.data) {
        // PR의 리뷰 목록 가져오기
        const reviews = await githubAPI.get(`/repos/${owner}/${repo}/pulls/${pr.number}/reviews`);
        
        // 마지막 확인 이후 새 리뷰가 있는지 확인
        const newReviews = reviews.data.filter(
          review => new Date(review.submitted_at) > lastCheckedTime
        );
        
        console.log(`PR #${pr.number}에 ${newReviews.length}개의 새 리뷰 발견`);
        
        // 새 리뷰가 있으면 알림 보내기
        for (const review of newReviews) {
          await sendDiscordNotification({
            title: `⚠️ PR에 새 리뷰가 등록되었습니다`,
            description: `PR: ${pr.title} - ${getReviewStateEmoji(review.state)} ${review.state}`,
            url: review.html_url,
            author: review.user.login,
            repo: `${owner}/${repo}`
          });
        }
      }
    } catch (error) {
      console.error(`Error checking reviews for ${owner}/${repo}:`, error.message);
    }
  }
}

// 새 이슈/이슈 댓글 확인
async function checkForNewIssuesAndComments() {
  console.log('이슈 및 이슈 댓글 확인 중...');
  
  for (const repoFullName of REPOS_TO_MONITOR) {
    const [owner, repo] = repoFullName.split('/');
    
    try {
      // 1. 나에게 할당된 이슈 확인
      const assignedIssues = await githubAPI.get(`/repos/${owner}/${repo}/issues`, {
        params: { 
          state: 'open', 
          assignee: GITHUB_USERNAME,
          since: lastCheckedTime.toISOString()
        }
      });
      
      console.log(`${assignedIssues.data.length}개의 새로 할당된 이슈 발견`);
      
      // 새로 할당된 이슈 알림
      for (const issue of assignedIssues.data) {
        // PR이 아닌 이슈만 처리 (PR도 이슈로 반환됨)
        if (!issue.pull_request) {
          await sendDiscordNotification({
            title: `📌 새 이슈가 할당되었습니다`,
            description: `이슈: ${issue.title}`,
            url: issue.html_url,
            author: issue.user.login,
            repo: `${owner}/${repo}`
          });
        }
      }
      
      // 2. 내가 생성한 이슈에 달린 새 댓글 확인
      const myIssues = await githubAPI.get(`/repos/${owner}/${repo}/issues`, {
        params: { 
          state: 'all', 
          creator: GITHUB_USERNAME 
        }
      });
      
      console.log(`${myIssues.data.length}개의 내가 생성한 이슈 발견`);
      
      // 이슈별로 새 댓글 확인
      for (const issue of myIssues.data) {
        // PR이 아닌 이슈만 처리
        if (!issue.pull_request) {
          const comments = await githubAPI.get(issue.comments_url);
          
          // 마지막 확인 이후 새 댓글이 있는지 확인
          const newComments = comments.data.filter(
            comment => 
              new Date(comment.created_at) > lastCheckedTime && 
              comment.user.login.toLowerCase() !== GITHUB_USERNAME.toLowerCase()
          );
          
          for (const comment of newComments) {
            await sendDiscordNotification({
              title: `💬 이슈에 새 댓글이 등록되었습니다`,
              description: `이슈: ${issue.title}\n${truncateText(comment.body, 100)}`,
              url: comment.html_url,
              author: comment.user.login,
              repo: `${owner}/${repo}`
            });
          }
        }
      }
      
      // 3. 내가 언급된(@username) 이슈/댓글 확인
      const mentionedIssues = await githubAPI.get(`/repos/${owner}/${repo}/issues`, {
        params: { 
          state: 'all', 
          mentioned: GITHUB_USERNAME,
          since: lastCheckedTime.toISOString()
        }
      });
      
      console.log(`${mentionedIssues.data.length}개의 내가 언급된 이슈 발견`);
      
      for (const issue of mentionedIssues.data) {
        if (!issue.pull_request) {
          await sendDiscordNotification({
            title: `🔔 이슈에서 언급되었습니다`,
            description: `이슈: ${issue.title}`,
            url: issue.html_url,
            author: issue.user.login,
            repo: `${owner}/${repo}`
          });
        }
      }
      
    } catch (error) {
      console.error(`Error checking issues for ${owner}/${repo}:`, error.message);
    }
  }
}

// Discussions 확인 (GitHub GraphQL API 사용)
async function checkForNewDiscussions() {
  console.log('디스커션 확인 중...');
  
  for (const repoFullName of REPOS_TO_MONITOR) {
    const [owner, repo] = repoFullName.split('/');
    
    try {
      // 1. 최근 디스커션 목록 가져오기 (GraphQL API 사용)
      const discussionsQuery = {
        query: `
          query {
            repository(owner: "${owner}", name: "${repo}") {
              discussions(first: 10, orderBy: {field: CREATED_AT, direction: DESC}) {
                nodes {
                  id
                  title
                  url
                  author {
                    login
                  }
                  createdAt
                  comments(first: 10, orderBy: {field: CREATED_AT, direction: DESC}) {
                    nodes {
                      id
                      author {
                        login
                      }
                      createdAt
                      url
                      bodyText
                    }
                  }
                }
              }
            }
          }
        `
      };
      
      const discussionsResponse = await axios.post(
        'https://api.github.com/graphql',
        discussionsQuery,
        {
          headers: {
            Authorization: `token ${GITHUB_TOKEN}`,
            'Content-Type': 'application/json'
          }
        }
      );
      
      const discussions = discussionsResponse.data.data.repository?.discussions?.nodes || [];
      
      console.log(`${discussions.length}개의 디스커션 발견`);
      
      // 2. 새 디스커션 확인
      for (const discussion of discussions) {
        const createdAt = new Date(discussion.createdAt);
        
        // 최근 생성된 디스커션 알림
        if (createdAt > lastCheckedTime) {
          // 본인이 작성한 것은 제외
          if (discussion.author.login.toLowerCase() !== GITHUB_USERNAME.toLowerCase()) {
            await sendDiscordNotification({
              title: `📣 새 디스커션이 생성되었습니다`,
              description: `디스커션: ${discussion.title}`,
              url: discussion.url,
              author: discussion.author.login,
              repo: `${owner}/${repo}`
            });
          }
        }
        
        // 3. 디스커션 댓글 확인
        const comments = discussion.comments.nodes || [];
        
        for (const comment of comments) {
          const commentCreatedAt = new Date(comment.createdAt);
          
          if (commentCreatedAt > lastCheckedTime && 
              comment.author.login.toLowerCase() !== GITHUB_USERNAME.toLowerCase()) {
                
            // 자신이 작성한 디스커션에 달린 댓글 알림
            if (discussion.author.login.toLowerCase() === GITHUB_USERNAME.toLowerCase()) {
              await sendDiscordNotification({
                title: `💬 내 디스커션에 새 댓글이 등록되었습니다`,
                description: `디스커션: ${discussion.title}\n${truncateText(comment.bodyText, 100)}`,
                url: comment.url,
                author: comment.author.login,
                repo: `${owner}/${repo}`
              });
            }
            
            // 자신의 댓글이 달린 디스커션의 새 댓글 알림
            else {
              const userCommented = comments.some(c => 
                c.author.login.toLowerCase() === GITHUB_USERNAME.toLowerCase() && 
                new Date(c.createdAt) < commentCreatedAt
              );
              
              if (userCommented) {
                await sendDiscordNotification({
                  title: `💬 내가 참여한 디스커션에 새 댓글이 등록되었습니다`,
                  description: `디스커션: ${discussion.title}\n${truncateText(comment.bodyText, 100)}`,
                  url: comment.url,
                  author: comment.author.login,
                  repo: `${owner}/${repo}`
                });
              }
            }
          }
        }
      }
    } catch (error) {
      console.error(`Error checking discussions for ${owner}/${repo}:`, error.message);
    }
  }
}

// 리뷰 상태에 따른 이모지 반환
function getReviewStateEmoji(state) {
  switch (state) {
    case 'APPROVED': return '✅';
    case 'CHANGES_REQUESTED': return '❌';
    case 'COMMENTED': return '💬';
    default: return '❓';
  }
}

// 텍스트 자르기 함수
function truncateText(text, maxLength) {
  if (!text) return '';
  if (text.length <= maxLength) return text;
  return text.substring(0, maxLength) + '...';
}

// Discord로 알림 보내기
async function sendDiscordNotification(data) {
  try {
    await axios.post(DISCORD_WEBHOOK_URL, {
      embeds: [{
        title: data.title,
        description: data.description,
        url: data.url,
        color: 3447003, // 파란색
        author: {
          name: data.author
        },
        footer: {
          text: `Repository: ${data.repo}`
        },
        timestamp: new Date()
      }]
    });
    console.log('알림 전송 완료:', data.title);
  } catch (error) {
    console.error('Discord 알림 전송 실패:', error.message);
  }
}

// 메인 함수
async function main() {
  try {
    // 각 기능별로 확인 실행
    await checkForReviewRequests();
    await checkForNewReviews();
    await checkForNewIssuesAndComments();
    await checkForNewDiscussions();
    
    console.log('모든 확인 완료');
  } catch (error) {
    console.error('실행 중 오류 발생:', error);
  }
}

// 스크립트 실행
main();


이제 액션에서 사용할 깃허브 개인 엑세스토큰과 디스코드 웹훅 URL을 만들고
Github Secrets에 저장해주면된다

Github 개인 엑세스 토큰은
본인 프로필을 누르고 setting에 들어가서

왼쪽 맨아래 Developer Setttings를 누르고

"Personal access tokens" → "Fine-grained tokens" 에서
토큰을 만들어주면된다

이름을 적절하게, 만료기간은 나는 좀 길게 설정해뒀다
갱신하기 귀찮아

Repository access는그냥 All로해줬고

Repository permissions

Repository persmissions을 펼쳐서 아래 항목을 read-only로 변경
Pull request, Commit statuses , contents, issues, discussion, metadata를 선택해준다

이러고 Generate Token시 나오는 토큰은 다시는 볼수 없으니 꼭 메모해두자


그리고 이제 Discord 웹훅 을 준비해야하는데
본인이 원하는 서버(본인이 관리하는서버여야함, 없으면 개인용으로 새로하나 파쇼)


본인 서버 좌상단에 있는 이름을 누르면 서버설정에 들어갈수 있는데
앱으로 분류되있는 연동 메뉴를 눌러서
웹 후크를 하나 만들어주고 URL을 복사하면된다

그럼 이제 Github Secret을 설정할차례인데
아까 만든 repo의 setting에 들어가서

Secrets and variables의 Actions에 들어가면

이런식의 창이뜨는데


New repository secret을 누르고
4가지 secret을 추가해주자 (Name은 변경가능하지만 아까 처음에 repo에서만든 action 정보를 이름에 맞게 수정해줘야함)


첫 번째 Secret:

  • Name: PERSONAL_GITHUB_TOKEN
  • Value: 아까 메모해둔 개인 액세스 토큰
  • "Add secret" 클릭

b. 두 번째 Secret:

  • Name: DISCORD_WEBHOOK_URL
  • Value: 복사한 Discord 웹훅 URL
  • "Add secret" 클릭

c. 세 번째 Secret:

  • Name: MY_GITHUB_USERNAME;
  • Value: 본인의 GitHub 사용자 이름 (저같은 경우는 Atriel1999)
  • "Add secret" 클릭

d. 네 번째 Secret:

  • Name: REPOS_TO_MONITOR
  • Value: 모니터링할 저장소 목록을 JSON 배열로 (예: ["owner1/repo1", "owner2/repo2"])
  • "Add secret" 클릭

설정이 완료됬다면
아까 repo의 actions 탭에서 아까 만든 플로우를 클릭하고
오른쪽 상단 드롭다운 메뉴에서 Run [워크플로우 이름] 혹은 이미 실행 중이거나 실패한 상태라면
Re-Run [워크플로우 이름]을 실행한다

Status가 Queued 로 되면 실행 중인거고 기다리면

이제 디스코드에서 알림이 오는지 확인하면 된다