[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 관리는 가능하다. 한번 확장해서 써보자.