[CI] Github Action로 PR 머지시 이슈 close / 라벨링 자동화

찬디·2025년 7월 21일

우테코

목록 보기
11/18
post-thumbnail

개요

이번에 우테코에서 진행하는 팀플을 진행하던중..
깃허브에서 항상 이슈를 만들때 라벨을 만들거나 PR이 머지됐는데도 이슈가 닫기지 않는 등 너무 귀찮은 점들이 많았따
GithubAction을 이용해 최대한 자동화해보자

  • GithubAction을 사용하면 깃허브에서 많은것을 자동화할 수 있으니 이번 기회에 공부해보자.
    • 사실 공부가 아니라 사용법을 익히는 것이다.
    • github에서 만들어놓은 도구사용법을 보고 익히는것과 똑같다. 따라서 깃허브 액션에 원리에 대한 것은 현재 글에서 다루지 않는다

상황

  • PR이 머지되면 이슈가 자동으로 닫기도록 하고 싶으면 어떡해야할까?
    • 팀원이 짜준 다음의 github action을 공부해보자.

이슈 클로즈 자동화 워크플로우

name: Close Linked Issue on PR Merge

on:
  pull_request:
    types:
      - closed

jobs:
  close_issue:
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    permissions:
      issues: write
    steps:
      - name: Close linked issues
        uses: actions/github-script@v7
        with:
          script: |
            const prBody = context.payload.pull_request.body || '';

            const issuePattern = /close\s+#(\d+)/gi;
            const matches = [...prBody.matchAll(issuePattern)];

            if (matches.length === 0) {
              console.log('No linked issues found.');
              return;
            }

            for (const match of matches) {
              const issueNumber = match[1];
              console.log(`Closing issue #${issueNumber}`);

              await github.rest.issues.update({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: Number(issueNumber),
                state: 'closed',
              });
            }

on:

  • pr이 닫길때 뭔갈 하겠다

jobs: 실제로할일
close issue: 이름정한것

Github Action 조건문

if: github.event.pull_request.merged == true

위와 같은 깃허브 이벤트라는 이름으로 관리되는 상태값들이 있다

위와 같은 것 말고도 여러가지 조합들로 원하는 조건을 정리할 수 있다

if: github.event.ref == 'refs/heads/main' //특정 브랜치에 푸쉬됐을때
if: !contains(github.event.pull_request.title, '[skip ci]') //pr의 title이 특정 문자열을 포함하지 않으면

permissions

위 작업을 할때 어떤 권한을 가지고 처리할지를 명시해야한다

설명typical value
contents코드, 커밋, 브랜치 접근read / write
pull-requestsPR 생성/코멘트/머지read / write
issues이슈 생성/코멘트/닫기read / write
checksstatus 체크 등록read / write
statuses커밋 status 변경read / write
deployments배포 생성 및 상태 변경read / write
actions워크플로우 관리 (trigger 등)read / write
packagesGitHub Packages 접근read / write
pagesGitHub Pages 접근read / write
id-tokenOpenID Connect 토큰 발급write (only)
repository-projectsGitHub Projects 접근read / write
security-eventsDependabot 등 보안 알림read
workflows다른 워크플로우 실행read / write

대충 위와 같다고하는데, 대충 눈으로 보고 필요할때 찾아보면 될것같다.

pr에 close 키워드 포함시 이슈 지우기

const prBody = context.payload.pull_request.body || '';

            const issuePattern = /close\s+#(\d+)/gi;
            const matches = [...prBody.matchAll(issuePattern)];
  • pr 본문을 가져온다
  • close #이슈번호 를 뽑아낸다
const matches = [...prBody.matchAll(issuePattern)];

            if (matches.length === 0) {
              console.log('No linked issues found.');
              return;
            }

            for (const match of matches) {
              const issueNumber = match[1];
              console.log(`Closing issue #${issueNumber}`);

              await github.rest.issues.update({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: Number(issueNumber),
                state: 'closed',
              });
            }

이슈가 여러개일 수 있으니 matches 에 for 방식으로 돌면서

  • 이슈번호 일치하는 것을 찾는다

  • 특이한점은, owner가 issue에 대한 owner가 아니라 레포지토리에 대한 owner라는 점

  • 권한이 있고, 이슈번호가 해당되면 해당 이슈 상태를 close로 변경한다

결과

다음과 같이 머지가 되면 직접 닫지않아도 이슈가 닫힌다

라벨링 자동화 워크플로우

이슈를 올릴때마다 be,feat 등 라벨링을 하는것도 귀찮다.

name: Setup Opened Issues

on:
  issues:
    types:
      - opened

jobs:
  setup_issue:
    runs-on: ubuntu-latest
    permissions:
      issues: write
      contents: read

    steps:
      - name: Set assignees
        continue-on-error: true
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const { issue, repository } = context.payload;
            const author = issue.user.login;

            await github.rest.issues.addAssignees({
              owner: repository.owner.login,
              repo: repository.name,
              issue_number: issue.number,
              assignees: [author],
            });

      - name: Set labels
        continue-on-error: true
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const { issue, repository } = context.payload;
            const title = issue.title;

            const titleLabelMap = {
              '[FEAT]': 'feat',
              '[FIX]': 'fix',
              '[CHORE]': 'chore',
              '[REFACTOR]': 'refactor',
              '[TEST]': 'test',
              '[DESIGN]': 'design',
              '[DOCS]': 'docs',
            };

            const feTeam = ['keemsebin', 'ExceptAnyone', 'yeji0214'];
            const beTeam = ['joon6093', 'abc5259', 'praisebak', 'jumdo12'];

            const prefix = Object.keys(titleLabelMap).find(p => title.startsWith(p));
            const author = issue.user.login;
            const authorLabel = feTeam.includes(author) ? 'fe' : beTeam.includes(author) ? 'be' : null;

            const labels = [];
            if (prefix) labels.push(titleLabelMap[prefix]);
            if (authorLabel) labels.push(authorLabel);

            if (labels.length) {
              await github.rest.issues.addLabels({
                owner: repository.owner.login,
                repo: repository.name,
                issue_number: issue.number,
                labels,
              });
            }

      - name: Set Project v2
        continue-on-error: true
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.PROJECT_V2_TOKEN }}
          script: |
            const { issue } = context.payload;
            const projectNodeId = 'PVT_kwDOA_44FM4A9RQT';
            const addItemResponse = await github.graphql(`
              mutation($projectId: ID!, $contentId: ID!) {
                addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
                  item { id }
                }
              }
            `, {
              projectId: projectNodeId,
              contentId: issue.node_id,
            });

            const itemId = addItemResponse.addProjectV2ItemById.item.id;

            const statusFieldId = 'PVTSSF_lADOA_44FM4A9RQTzgxBWgo';
            const todoOptionId = 'f75ad846';

            await github.graphql(`
              mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
                updateProjectV2ItemFieldValue(input: {
                  projectId: $projectId,
                  itemId: $itemId,
                  fieldId: $fieldId,
                  value: { singleSelectOptionId: $optionId }
                }) {
                  projectV2Item { id }
                }
              }
            `, {
              projectId: projectNodeId,
              itemId,
              fieldId: statusFieldId,
              optionId: todoOptionId,
            });

      - name: Set milestone
        continue-on-error: true
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const { issue, repository } = context.payload;
            const today = new Date();
            const milestones = await github.paginate('GET /repos/' + repository.owner.login + '/' + repository.name + '/milestones', {
              state: 'open'
            });

            const milestone = milestones.find(m => {
              if (!m.due_on) return false;
              const startDate = new Date(m.created_at);
              const dueDate = new Date(m.due_on);
              return startDate <= today && today <= dueDate;
            });

            if (milestone) {
              await github.request('PATCH /repos/' + repository.owner.login + '/' + repository.name + '/issues/' + issue.number, {
                milestone: milestone.number
              });
            } else {
              console.log('No active milestone found for today.');
            }

워크플로우 단계별 분석

1. Assignees 등록

const { issue, repository } = context.payload;
const author = issue.user.login;

await github.rest.issues.addAssignees({
  owner: repository.owner.login,
  repo: repository.name,
  issue_number: issue.number,
  assignees: [author],
});
  • 이슈를 작성한 사람을 자동으로 해당 이슈의 담당자로 지정한다
  • context.payload에서 이슈 정보와 저장소 정보를 가져온다
  • issue.user.login으로 이슈 작성자의 깃허브 아이디를 가져온다
  • github.rest.issues.addAssignees API를 사용해 담당자를 추가한다

2. 라벨 자동 설정

const titleLabelMap = {
  '[FEAT]': 'feat',
  '[FIX]': 'fix',
  '[CHORE]': 'chore',
  '[REFACTOR]': 'refactor',
  '[TEST]': 'test',
  '[DESIGN]': 'design',
  '[DOCS]': 'docs',
};

const feTeam = ['', '', ''];
const beTeam = ['', '', 'praisebak', ''];
  • 이슈 제목의 prefix에 따라 자동으로 라벨을 부여한다
  • 예: [FEAT] 로그인 기능 추가feat 라벨 자동 추가
  • 이슈 작성자가 FE팀인지 BE팀인지에 따라 fe 또는 be 라벨도 자동으로 추가한다

3. GitHub Project v2 연동

const projectNodeId = '프로젝트노드아이디';
const addItemResponse = await github.graphql(`
  mutation($projectId: ID!, $contentId: ID!) {
    addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
      item { id }
    }
  }
`, {
  projectId: projectNodeId,
  contentId: issue.node_id,
});
  • GitHub의 새로운 Project v2에 이슈를 자동으로 추가한다
  • GraphQL API를 사용하여 프로젝트에 이슈를 연결한다
  • 이슈가 생성되면 자동으로 프로젝트 보드의 "Todo" 상태로 설정된다

4. 마일스톤 자동 설정

const today = new Date();
const milestones = await github.paginate('GET /repos/' + repository.owner.login + '/' + repository.name + '/milestones', {
  state: 'open'
});

const milestone = milestones.find(m => {
  if (!m.due_on) return false;
  const startDate = new Date(m.created_at);
  const dueDate = new Date(m.due_on);
  return startDate <= today && today <= dueDate;
});
  • 현재 날짜를 기준으로 활성 상태인 마일스톤을 찾는다
  • 마일스톤의 시작일과 마감일 사이에 오늘 날짜가 포함되면 해당 마일스톤을 이슈에 할당한다
  • 이를 통해 스프린트나 특정 기간의 작업을 자동으로 분류할 수 있다

continue-on-error 속성

continue-on-error: true

각 단계에서 이 속성을 사용하는 이유는 한 단계가 실패해도 다른 단계들이 계속 실행되도록 하기 위함이다. 예를 들어, 라벨 설정이 실패해도 프로젝트 연동이나 마일스톤 설정은 정상적으로 진행될 수 있다.

활용 예시

이슈 생성 시

제목: [FEAT] 사용자 인증 기능 구현
작성자: praisebak

위와 같은 이슈가 생성되면 자동으로:
1. 담당자: praisebak
2. 라벨: feat, be
3. 프로젝트: 자동으로 프로젝트 보드에 추가되고 "Todo" 상태
4. 마일스톤: 현재 진행 중인 스프린트에 할당

마무리

이런 식으로 GitHub Action을 활용하면 반복적인 이슈 관리 작업을 대폭 줄일 수 있다.
특히 나는 이슈 관리에서 라벨을 빼먹는 실수를 자주하는데 너무 편했다.

profile
깃허브에서 velog로 블로그를 이전했습니다.

1개의 댓글

comment-user-thumbnail
2025년 8월 1일

예, 그 팀원이 저임니다
팀원이 행복한 모습을 상상하며 열심히 만들었죠 ㅎ

답글 달기