[Github] Project로 Task & Issue 관리하기
Github은 꽤나 강력한 Github Action 기반의 워크플로 제어 기능을 제공한다.
Project와 Action을 잘 활용한다면 Github을 가볍게 쓸만한 Task Management 도구로서도 활용할 수 있다.
Project 구성
일단 프로젝트 보드를 만들고, 원하는 수준에 맞춰서 STEP을 구성한다.
내 경우에는 Todo, In Progress, In Review, Done 4단계로 나눴다.
이건 작업의 성질에 따라서 크게 달라질 수 있다.
밑준비
워크플로로 제어를 원활하게 하고 싶다면 레포지토리에 시크릿으로 깃헙 토큰을 넣어주는게 좋다.
적당히 만들어서 심어준다.
Action으로 워크플로 제어
Action을 기반으로 해서 각각의 Task 카드를 제어하도록 구성해보겠다.

내가 가장 먼저 하고 싶은 것은, Github Issue를 만들었을때 프로젝트에도 카드가 만들어지게 하는 것이다.
다음은 해당 로직을 수행하는 워크플로 코드다.
name: add "Todo" card to project
on:
issues:
types: [opened, reopened]
jobs:
track_unlabeled:
runs-on: ubuntu-latest
steps:
- name: Get project data
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
ORGANIZATION: tokkitang (조직명)
PROJECT_NUMBER: 2 (프로젝트 번호)
run: |
gh api graphql -f query='
query($org: String!, $number: Int!) {
organization(login: $org){
projectV2(number: $number) {
id
fields(first:20) {
nodes {
... on ProjectV2Field {
id
name
}
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}' -f org=$ORGANIZATION -F number=$PROJECT_NUMBER > project_data.json
cat project_data.json
echo 'PROJECT_ID='$(jq '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV
echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
echo 'ISSUE_CREATE_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="Todo") |.id' project_data.json) >> $GITHUB_ENV
- name: Get Issue node_id
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
ISSUE_ID: ${{ github.event.issue.node_id }}
run: |
item_id="$( gh api graphql -f query='
mutation($project:ID!, $pr:ID!) {
addProjectV2ItemById(input: {projectId: $project, contentId: $pr}) {
item {
id
}
}
}' -f project=$PROJECT_ID -f pr=$ISSUE_ID --jq '.data.addProjectV2ItemById.item.id')"
echo 'ITEM_ID='$item_id >> $GITHUB_ENV
- name: Set fields
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
gh api graphql -f query='
mutation (
$project: ID!
$item: ID!
$status_field: ID!
$status_value: String!
) {
set_status: updateProjectV2ItemFieldValue(input: {
projectId: $project
itemId: $item
fieldId: $status_field
value: {
singleSelectOptionId: $status_value
}
}) {
projectV2Item {
id
}
}
}' -f project=${{ env.PROJECT_ID }} -f item=${{ env.ITEM_ID }} -f status_field=${{ env.STATUS_FIELD_ID }} -f status_value=${{ env.ISSUE_CREATE_ID }} --silent
그리고 만약, 이슈에서 브랜치를 생성한다면 우리는 그 작업이 시작되었다고 가정할 수 있을 것이다.
아래는 브랜치 생성을 감지해서 기존에 이슈 생성으로 만들어진 카드를 In Progress로 옮긴다.
name: Move to "In Progress" when branch created
on:
create:
branches:
- 'feat/#*'
jobs:
get_node_id:
if: startsWith(github.ref, 'refs/heads/feat/#')
runs-on: ubuntu-latest
steps:
- name: Install jq
run: sudo apt-get install jq
- name: Get Issue node_id
run: |
BRANCH_NAME=${GITHUB_REF#refs/heads/}
ISSUE_NUMBER=${BRANCH_NAME#feat/#}
echo "Issue Number: $ISSUE_NUMBER"
TOKEN=${{ secrets.GH_TOKEN }}
REPO_OWNER=${{ github.repository_owner }}
REPO_NAME=${{ github.event.repository.name }}
QUERY="query { repository(owner: \\\"$REPO_OWNER\\\", name: \\\"$REPO_NAME\\\") { issue(number: $ISSUE_NUMBER) { id } } }"
DATA="{\"query\": \"$QUERY\"}"
RESPONSE=$(curl -s -H "Authorization: Bearer $TOKEN" -X POST -H "Content-Type: application/json" --data "$DATA" https://api.github.com/graphql)
NODE_ID=$(echo $RESPONSE | jq -r '.data.repository.issue.id')
if [ "$NODE_ID" == "null" ]; then
echo "Error: Invalid response from GitHub API"
echo "Response: $RESPONSE"
exit 1
fi
echo "Node ID: $NODE_ID"
echo "NODE_ID=$NODE_ID" >> $GITHUB_ENV
- name: Get project data
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
ORGANIZATION: tokkitang
PROJECT_NUMBER: 2
run: |
gh api graphql -f query='
query($org: String!, $number: Int!) {
organization(login: $org){
projectV2(number: $number) {
id
fields(first:20) {
nodes {
... on ProjectV2Field {
id
name
}
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}' -f org=$ORGANIZATION -F number=$PROJECT_NUMBER > project_data.json
echo 'PROJECT_ID='$(jq '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV
echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
echo 'ISSUE_CREATE_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="In Progress") |.id' project_data.json) >> $GITHUB_ENV
- name: Get Issue node_id
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
ISSUE_ID: ${{ env.NODE_ID }}
run: |
item_id="$( gh api graphql -f query='
mutation($project:ID!, $pr:ID!) {
addProjectV2ItemById(input: {projectId: $project, contentId: $pr}) {
item {
id
}
}
}' -f project=$PROJECT_ID -f pr=$ISSUE_ID --jq '.data.addProjectV2ItemById.item.id')"
echo 'ITEM_ID='$item_id >> $GITHUB_ENV
- name: Set fields
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
gh api graphql -f query='
mutation (
$project: ID!
$item: ID!
$status_field: ID!
$status_value: String!
) {
set_status: updateProjectV2ItemFieldValue(input: {
projectId: $project
itemId: $item
fieldId: $status_field
value: {
singleSelectOptionId: $status_value
}
}) {
projectV2Item {
id
}
}
}' -f project=${{ env.PROJECT_ID }} -f item=${{ env.ITEM_ID }} -f status_field=${{ env.STATUS_FIELD_ID }} -f status_value=${{ env.ISSUE_CREATE_ID }} --silent
대신 브랜치명을 생성할때는 관례적으로 feat/#이슈번호 와 같은 식으로 해줘야 한다.
그래야 명시적으로 이슈 정보를 추적할 수 있다.
그리고 Pull Request가 생성된다면 어느정도 작업이 끝났고, 리뷰중이라고 가정할 수 있을 것이다. 아래는 그것을 수행하는 워크플로 코드다.
name: Move to "In Review" when PR created
on:
pull_request:
types: [opened, edited, synchronize]
jobs:
resolve_issue:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Install required packages
run: |
sudo apt-get update
sudo apt-get -y install jq grep
- name: Check if issue is resolved
run: |
# Get the pull request body
PR_BODY=$(jq --raw-output .pull_request.body $GITHUB_EVENT_PATH)
# Find the issue number from the PR body
ISSUE_NUMBER=$(echo "$PR_BODY" | grep -oE '\bresolved: #[0-9]+' | cut -d'#' -f2 | cut -d' ' -f1)
# Check if issue number is not empty
if [ -n "$ISSUE_NUMBER" ]; then
echo "ISSUE NUMBER: $ISSUE_NUMBER"
echo "ISSUE_NUMBER=$ISSUE_NUMBER" >> $GITHUB_ENV
fi
- name: Get Issue node_id
run: |
TOKEN=${{ secrets.GH_TOKEN }}
REPO_OWNER=${{ github.repository_owner }}
REPO_NAME=${{ github.event.repository.name }}
QUERY="query { repository(owner: \\\"$REPO_OWNER\\\", name: \\\"$REPO_NAME\\\") { issue(number: ${{ env.ISSUE_NUMBER }}) { id } } }"
DATA="{\"query\": \"$QUERY\"}"
RESPONSE=$(curl -s -H "Authorization: Bearer $TOKEN" -X POST -H "Content-Type: application/json" --data "$DATA" https://api.github.com/graphql)
NODE_ID=$(echo $RESPONSE | jq -r '.data.repository.issue.id')
if [ "$NODE_ID" == "null" ]; then
echo "Error: Invalid response from GitHub API"
echo "Response: $RESPONSE"
exit 1
fi
echo "Node ID: $NODE_ID"
echo "NODE_ID=$NODE_ID" >> $GITHUB_ENV
- name: Get project data
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
ORGANIZATION: tokkitang
PROJECT_NUMBER: 2
run: |
gh api graphql -f query='
query($org: String!, $number: Int!) {
organization(login: $org){
projectV2(number: $number) {
id
fields(first:20) {
nodes {
... on ProjectV2Field {
id
name
}
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}' -f org=$ORGANIZATION -F number=$PROJECT_NUMBER > project_data.json
echo 'PROJECT_ID='$(jq '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV
echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
echo 'ISSUE_CREATE_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="In Review") |.id' project_data.json) >> $GITHUB_ENV
- name: Add PR to project
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
ISSUE_ID: ${{ env.NODE_ID }}
run: |
item_id="$( gh api graphql -f query='
mutation($project:ID!, $pr:ID!) {
addProjectV2ItemById(input: {projectId: $project, contentId: $pr}) {
item {
id
}
}
}' -f project=$PROJECT_ID -f pr=$ISSUE_ID --jq '.data.addProjectV2ItemById.item.id')"
echo 'ITEM_ID='$item_id >> $GITHUB_ENV
- name: Set fields
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
gh api graphql -f query='
mutation (
$project: ID!
$item: ID!
$status_field: ID!
$status_value: String!
) {
set_status: updateProjectV2ItemFieldValue(input: {
projectId: $project
itemId: $item
fieldId: $status_field
value: {
singleSelectOptionId: $status_value
}
}) {
projectV2Item {
id
}
}
}' -f project=${{ env.PROJECT_ID }} -f item=${{ env.ITEM_ID }} -f status_field=${{ env.STATUS_FIELD_ID }} -f status_value=${{ env.ISSUE_CREATE_ID }} --silent
이슈가 머지된다면 Project는 자동으로 상태를 Done으로 이동시킨다.
아마도 이슈가 닫힐때 연관된 카드를 end status로 전이시키는 것이 기본 동작인 것 같다.
이 정도만 되어도 간단한 수준의 Task 관리는 가능하다. 한번 확장해서 써보자.