이번에 우테코에서 진행하는 팀플을 진행하던중..
깃허브에서 항상 이슈를 만들때 라벨을 만들거나 PR이 머지됐는데도 이슈가 닫기지 않는 등 너무 귀찮은 점들이 많았따
GithubAction을 이용해 최대한 자동화해보자
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:
jobs: 실제로할일
close issue: 이름정한것
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이 특정 문자열을 포함하지 않으면
위 작업을 할때 어떤 권한을 가지고 처리할지를 명시해야한다
| 키 | 설명 | typical value |
|---|---|---|
contents | 코드, 커밋, 브랜치 접근 | read / write |
pull-requests | PR 생성/코멘트/머지 | read / write |
issues | 이슈 생성/코멘트/닫기 | read / write |
checks | status 체크 등록 | read / write |
statuses | 커밋 status 변경 | read / write |
deployments | 배포 생성 및 상태 변경 | read / write |
actions | 워크플로우 관리 (trigger 등) | read / write |
packages | GitHub Packages 접근 | read / write |
pages | GitHub Pages 접근 | read / write |
id-token | OpenID Connect 토큰 발급 | write (only) |
repository-projects | GitHub Projects 접근 | read / write |
security-events | Dependabot 등 보안 알림 | read |
workflows | 다른 워크플로우 실행 | read / write |
대충 위와 같다고하는데, 대충 눈으로 보고 필요할때 찾아보면 될것같다.
const prBody = context.payload.pull_request.body || '';
const issuePattern = /close\s+#(\d+)/gi;
const matches = [...prBody.matchAll(issuePattern)];
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.');
}
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를 사용해 담당자를 추가한다const titleLabelMap = {
'[FEAT]': 'feat',
'[FIX]': 'fix',
'[CHORE]': 'chore',
'[REFACTOR]': 'refactor',
'[TEST]': 'test',
'[DESIGN]': 'design',
'[DOCS]': 'docs',
};
const feTeam = ['', '', ''];
const beTeam = ['', '', 'praisebak', ''];
[FEAT] 로그인 기능 추가 → feat 라벨 자동 추가fe 또는 be 라벨도 자동으로 추가한다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,
});
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: true
각 단계에서 이 속성을 사용하는 이유는 한 단계가 실패해도 다른 단계들이 계속 실행되도록 하기 위함이다. 예를 들어, 라벨 설정이 실패해도 프로젝트 연동이나 마일스톤 설정은 정상적으로 진행될 수 있다.
제목: [FEAT] 사용자 인증 기능 구현
작성자: praisebak
위와 같은 이슈가 생성되면 자동으로:
1. 담당자: praisebak
2. 라벨: feat, be
3. 프로젝트: 자동으로 프로젝트 보드에 추가되고 "Todo" 상태
4. 마일스톤: 현재 진행 중인 스프린트에 할당
이런 식으로 GitHub Action을 활용하면 반복적인 이슈 관리 작업을 대폭 줄일 수 있다.
특히 나는 이슈 관리에서 라벨을 빼먹는 실수를 자주하는데 너무 편했다.
예, 그 팀원이 저임니다
팀원이 행복한 모습을 상상하며 열심히 만들었죠 ㅎ