11
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

こんにちは。
本記事は LIFULL Advent Calendar 2025の6日目の記事です。

こんにちは、新卒四年目アプリケーションエンジニアの山田と申します。普段はLIFULL HOME'Sというサービスの賃貸領域の開発を行っております。

私の所属する賃貸領域では、企画・エンジニア・デザイナーの3部門による「賃貸モノづくり組織」のもと、部門横断的なチームを編成し、三位一体となって事業を推進しております。

現在、私たちは開発タスクの管理に GitHub Projects (V2) を利用しています。

今回は、複数のチーム・プロジェクトに分散しているIssue情報を、「部署全体のMaster Project」に自動集約し、日付やステータスまで完全同期する仕組み を構築したので、その知見を共有します。

標準機能(Workflows)だけでは手の届かない、「Select Fieldの動的生成」や「不要オプションのお掃除」までを、GitHub CLI (gh) と GraphQL で泥臭く実装した話です。

背景と課題

2年越しのGitHub Projects移行と、変わらない課題

私の部署ではもともとJiraを利用していましたが、開発リポジトリと密接に連携できる体験を求め、約2年かけてGitHub Projectsへの移行を推進してきました。私がそもそもJiraのProject管理機能を使いこなせなかったため、自分がテックリードとしてアサインされたチームではGithub Projectsの良さを解き、そちらを採用する形で啓蒙してきました。
地道な啓蒙活動の甲斐あって、10月に新たな期が始まった段階で部署全体がGitHub Projectsを活用することになりました。

しかし、ツールが移行できても、以前(Jira時代)から解決できていない根深い課題が残っていました。

「メンバーのタスク状況が、チームごとに分断されている」

部署内には複数のチームが存在し、現在7〜8個のプロジェクトが並行して動いています。
一方で、 「複数のチームを兼務しているメンバー」 は少なくありません。

各チームの定例ではそれぞれのプロジェクトしか見ないため、 「AさんはチームXで忙しいのに、チームYでもタスクを積まれてパンク寸前\」 といった状況が、誰にも気づかれないまま発生していました。また、それを危惧して逐一メンバーにお伺いを立てないとタスクをお願いできないというような状況でした。

ひらめき

Jira時代はこの課題に対して打つ手が限られていましたが、GitHub Projectsに移行した今なら、 APIとGitHub Actionsを使えばこの壁を突破できる はずです。

そこで、「各チームの運用(入力の手間)は変えずに、データだけを裏側でMaster Projectに集約する」仕組みを作ることにしました。

実現したアーキテクチャ

やりたいことはシンプルです。

  1. 各チームのプロジェクト (入力用): メンバーはここで普段通りIssueを更新
  2. 同期スクリプト : 定期的に全Issueを走査
  3. Master Project (閲覧用): 全Issueが集約され、日付・ステータス・Epic情報が同期される

これを実現するために、いくつかのWorkflowとShell Scriptを組み合わせました。

実装の詳細とコード解説

ここからは、実際に稼働しているコードをすべて紹介しながら、それぞれの役割を解説します。

前提

この仕組みを動かすためには、事前にいくつかのID取得と、GitHubリポジトリへの環境変数・Secretの設定が必要です。

1. 必要なIDの取得

GitHub CLI (gh) を使用して、集約先となるMaster Projectの各種IDを取得します。

# 1. Project ID (Node ID) の取得
# 出力例: "id": "PVT_kwDO..."
gh project list --owner <組織名> --format json --jq '.projects[] | {title, id}'

# 2. 各フィールドIDの取得
# 出力例: "name": "Status", "id": "PVTSSF_..."
gh project field-list <Project番号> --owner <組織名> --format json --jq '.fields[] | {name, id}'

取得が必要なIDは以下の4つです。

  1. Project Node ID: 集約先のプロジェクト自体のID(PVT_...
  2. Start Date Field ID: 開始日フィールドのID
  3. End Date Field ID: 終了日フィールドのID
  4. Status Field ID: ステータスフィールドのID

2. Secrets と Variables の登録

リポジトリの Settings > Secrets and variables > Actions から以下の値を登録してください。

種類 Name Value 備考
Secrets PROJECT_PAT Personal Access Token repo, project, read:org 権限が必要。
GITHUB_TOKENでは権限不足のため必須。
Variables PROJECT_ID PVT_... 先ほど取得したProject Node ID

3. スクリプト内の定数書き換え

取得したフィールドID(Start Date, End Date, Status)は、後述する .github/scripts/sync-project-dates.sh の冒頭にある変数定義へ直接書き込みます。

# 環境に合わせて書き換えてください
START_DATE_FIELD_ID="$(PVTF_EXAMPLE_ID_1)"
END_DATE_FIELD_ID="$(PVTF_EXAMPLE_ID_2)"
STATUS_FIELD_ID="$(PVTF_EXAMPLE_ID_3)"

4. リポジトリについて

リポジトリは弊部署のProjectのIssueを管理する専用のリポジトリを作成してあります。
その中に後述する actionsたちを配置する形になります。

これは改善ポイントではあるんですが、他アプリケーションを管理しているリポジトリに切られたissueについては手動でMaster Projectsに登録する必要があります。

私たちの部署では大部分がこのリポジトリに切られるIssueで事足りるのでそこまで手間になっていないのが現状です。

1. 定期同期ワークフロー

まず、これが同期の心臓部となる定期実行ワークフローです。
平日の業務開始前(7:00)、全チームの朝の定例が終わる時間(12:00)、弊社のコアタイムが終わる時間(16:00)に実行されるようにスケジュールしています。
リポジトリ内の全Open Issueを取得し、後述する同期スクリプト(sync-project-dates.sh)に流し込みます。

.github/workflows/sync-project-dates.yml
name: Sync Project Dates
on:
  schedule:
    - cron: '0 22 * * 0-4'  # 月-金 7:00 JST (UTC 22:00 前日)
    - cron: '0 3 * * 1-5'   # 月-金 12:00 JST (UTC 3:00)
    - cron: '0 7 * * 1-5'   # 月-金 16:00 JST (UTC 7:00)
  workflow_dispatch: {}  # 手動実行も可能

jobs:
  sync-dates:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Sync dates for all issues
        env:
          GH_TOKEN: ${{ secrets.PROJECT_PAT }}
          REPO: ${{ github.repository }}
          PROJECT_ID: ${{ vars.PROJECT_ID }}
        run: |
          echo "Starting date sync for all open issues..."
          gh issue list --repo "$REPO" --state open --limit 1000 --json number,id | \
          jq -r '.[] | .id' | \
          while read -r issue_id; do
            echo "Syncing dates for issue: $issue_id"
            .github/scripts/sync-project-dates.sh "$issue_id" "$PROJECT_ID" || echo "Date sync failed: $issue_id"
          done
          echo "Date sync completed"

2. 既存Issueの一括追加ワークフロー

こちらは手動実行(workflow_dispatch)専用です。
Master Projectへの集約を初めて行う際や、何らかの理由で同期漏れがあった場合に、既存の全Open Issueを強制的にプロジェクトに追加し、同期を行います。

.github/workflows/add-existing-issues-to-project.yml
name: Add Existing Issues to Project

on:
  workflow_dispatch: {}

jobs:
  add-existing-issues:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Add all open issues to project
        env:
          GH_TOKEN: ${{ secrets.PROJECT_PAT }}
          REPO: ${{ github.repository }}
          PROJECT_ID: ${{ vars.PROJECT_ID }}
        run: |
          gh issue list --repo "$REPO" --state open --limit 1000 --json number,id | \
          jq -r '.[] | .id' | \
          while read -r issue_id; do
            echo "Adding issue: $issue_id"
            gh api graphql -f query='
              mutation($projectId: ID!, $contentId: ID!) {
                addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
                  item {
                    id
                  }
                }
              }
            ' -f projectId="$PROJECT_ID" -f contentId="$issue_id" || echo "Failed or already added: $issue_id"
            
            echo "Syncing dates for issue: $issue_id"
            .github/scripts/sync-project-dates.sh "$issue_id" "$PROJECT_ID" || echo "Date sync failed: $issue_id"
          done

3. 新規Issue作成時の即時追加ワークフロー

Issueが opened になった瞬間発火します。
定期実行を待たずに、作成されたIssueを即座にMaster Projectへ追加し、初期状態の同期を行います。これにより、プロジェクト側の情報は常に最新に近い状態が保たれます。

.github/workflows/add-issue-to-project.yml
name: Add Issue to Project

on:
  issues:
    types: [opened]

jobs:
  add-to-project:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Add issue to project
        env:
          GH_TOKEN: ${{ secrets.PROJECT_PAT }}
          ISSUE_ID: ${{ github.event.issue.node_id }}
          PROJECT_ID: ${{ vars.PROJECT_ID }}
        run: |
          gh api graphql -f query='
            mutation($projectId: ID!, $contentId: ID!) {
              addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
                item {
                  id
                }
              }
            }
          ' -f projectId="$PROJECT_ID" -f contentId="$ISSUE_ID"

      - name: Sync dates from other projects
        env:
          GH_TOKEN: ${{ secrets.PROJECT_PAT }}
          ISSUE_ID: ${{ github.event.issue.node_id }}
          PROJECT_ID: ${{ vars.PROJECT_ID }}
        run: |
          .github/scripts/sync-project-dates.sh "$ISSUE_ID" "$PROJECT_ID"

4. 不要なEpicオプションのクリーンアップワークフロー

後述する同期スクリプトでは、必要なEpicなどの選択肢を「動的に作成」します。しかし、それを繰り返すと、使われなくなった古い選択肢がゴミとして残ってしまいます。
このワークフローは毎週月曜日に実行され、誰にも使われていない不要な選択肢を削除します。安全のため、隔週実行の制御やDry Runモードも実装しています。

.github/workflows/cleanup-epic-options.yml
name: Cleanup Unused Epic Options

on:
  schedule:
    - cron: '0 0 * * 1'  # 毎週月曜日 9:00 JST (UTC 0:00)
  workflow_dispatch:
    inputs:
      dry_run:
        description: 'Dry run mode (true/false)'
        required: false
        default: 'false'
        type: choice
        options:
          - 'true'
          - 'false'

jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Cleanup unused Epic options
        env:
          GH_TOKEN: ${{ secrets.PROJECT_PAT }}
          PROJECT_ID: ${{ vars.PROJECT_ID }}
          GITHUB_REPOSITORY: ${{ github.repository }}
        run: |
          DRY_RUN="${{ github.event.inputs.dry_run || 'false' }}"
          
          # 隔週実行の制御(週番号が偶数の週のみ実行)
          if [ "${{ github.event_name }}" = "schedule" ]; then
            WEEK_NUMBER=$(date +%V)
            if [ $((WEEK_NUMBER % 2)) -ne 0 ]; then
              echo "Skipping cleanup this week (odd week number: $WEEK_NUMBER)"
              exit 0
            fi
            echo "Running cleanup (even week number: $WEEK_NUMBER)"
          fi
          
          chmod +x .github/scripts/cleanup-unused-epic-options.sh
          .github/scripts/cleanup-unused-epic-options.sh "$PROJECT_ID" "$DRY_RUN"

5. 【核心】同期スクリプト本体

これがシステムの中核となるスクリプトです。以下の複雑な処理を一手に引き受けています。

  1. 情報の取得: 対象Issueが所属している「他の全プロジェクト」の情報をGraphQLで取得
  2. 最新値の決定: 複数のプロジェクトに属している場合、最も新しく更新された日付やStatusを採用
  3. Single Selectの動的同期:
    • EpicやTeam名を取得
    • Master Project側に「同じ名前の選択肢」が存在するか確認
    • 存在しなければAPIで新規作成(ここが重要)
    • 作成/取得したIDを使って値をセット
.github/scripts/sync-project-dates.sh
#!/bin/bash
set -e

ISSUE_ID=$1
TARGET_PROJECT_ID=$2
START_DATE_FIELD_ID="$(PVTF_EXAMPLE_ID_1)"
END_DATE_FIELD_ID="$(PVTF_EXAMPLE_ID_2)"
STATUS_FIELD_ID="$(PVTF_EXAMPLE_ID_3)"

# ターゲットプロジェクトのEPICフィールド情報を取得
target_project_fields=$(gh api graphql -f query='
  query($projectId: ID!) {
    node(id: $projectId) {
      ... on ProjectV2 {
        fields(first: 20) {
          nodes {
            ... on ProjectV2SingleSelectField {
              id
              name
              options {
                id
                name
              }
            }
          }
        }
      }
    }
  }
' -f projectId="$TARGET_PROJECT_ID")

EPIC_FIELD_ID=$(echo "$target_project_fields" | jq -r '
  .data.node.fields.nodes[] | select(.name | type == "string" and (. == "EPIC" or . == "Epic" or . == "epic")) | .id
')

if [ -z "$EPIC_FIELD_ID" ]; then
  echo "EPIC field not found in target project"
fi

TEAM_FIELD_ID=$(echo "$target_project_fields" | jq -r '
  .data.node.fields.nodes[] | select(.name == "Team") | .id
')

if [ -z "$TEAM_FIELD_ID" ]; then
  echo "Team field not found in target project"
fi

# Issueが属する全プロジェクトの日付とStatus情報を取得
projects_data=$(gh api graphql -f query='
  query($issueId: ID!) {
    node(id: $issueId) {
      ... on Issue {
        projectItems(first: 20) {
          nodes {
            id
            project {
              id
              title
            }
            updatedAt
            fieldValues(first: 20) {
              nodes {
                ... on ProjectV2ItemFieldDateValue {
                  field {
                    ... on ProjectV2Field {
                      name
                    }
                  }
                  date
                }
                ... on ProjectV2ItemFieldSingleSelectValue {
                  field {
                    ... on ProjectV2SingleSelectField {
                      name
                    }
                  }
                  name
                  optionId
                }
              }
            }
          }
        }
      }
    }
  }
' -f issueId="$ISSUE_ID")

# 最新のStart DateとEnd Dateを抽出
latest_start=$(echo "$projects_data" | jq -r '
  [.data.node.projectItems.nodes[].fieldValues.nodes[] | 
   select(.field.name == "Start Date" or .field.name == "Start date") | 
   .date] | map(select(. != null)) | sort | last // ""
')

latest_end=$(echo "$projects_data" | jq -r '
  [.data.node.projectItems.nodes[].fieldValues.nodes[] | 
   select(.field.name == "End Date" or .field.name == "End date") | 
   .date] | map(select(. != null)) | sort | last // ""
')

# 最後に更新されたStatusを抽出(updatedAtでソート)
latest_status=$(echo "$projects_data" | jq -r '
  [.data.node.projectItems.nodes[] | 
   select(.project.id != "'"$TARGET_PROJECT_ID"'") |
   {
     updatedAt: .updatedAt,
     status: (.fieldValues.nodes[] | select(.field.name == "Status") | .optionId)
   }] | 
  sort_by(.updatedAt) | 
  last | 
  .status // ""
')

# 最後に更新されたEpicを抽出(updatedAtでソート)
latest_epic_name=$(echo "$projects_data" | jq -r '
  [.data.node.projectItems.nodes[] | 
   select(.project.id != "'"$TARGET_PROJECT_ID"'") |
   {
     updatedAt: .updatedAt,
     epic: (.fieldValues.nodes[] | select(.field.name | type == "string" and (. == "EPIC" or . == "Epic" or . == "epic")) | .name)
   }] | 
  sort_by(.updatedAt) | 
  last | 
  .epic // ""
')

# 最後に更新されたプロジェクト名を抽出(updatedAtでソート)
latest_project_name=$(echo "$projects_data" | jq -r '
  [.data.node.projectItems.nodes[] | 
   select(.project.id != "'"$TARGET_PROJECT_ID"'") |
   {
     updatedAt: .updatedAt,
     projectName: .project.title
   }] | 
  sort_by(.updatedAt) | 
  last | 
  .projectName // ""
')

# MasterプロジェクトのアイテムIDを取得
target_item_id=$(echo "$projects_data" | jq -r --arg pid "$TARGET_PROJECT_ID" '
  .data.node.projectItems.nodes[] | select(.project.id == $pid) | .id
')

if [ -z "$target_item_id" ]; then
  echo "Issue not found in target project"
  exit 1
fi

# Start Dateを更新
if [ -n "$latest_start" ]; then
  gh api graphql -f query='
    mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: Date!) {
      updateProjectV2ItemFieldValue(input: {
        projectId: $projectId
        itemId: $itemId
        fieldId: $fieldId
        value: {date: $value}
      }) {
        projectV2Item {
          id
        }
      }
    }
  ' -f projectId="$TARGET_PROJECT_ID" -f itemId="$target_item_id" \
    -f fieldId="$START_DATE_FIELD_ID" -f value="$latest_start"
  echo "Updated Start Date: $latest_start"
fi

# End Dateを更新
if [ -n "$latest_end" ]; then
  gh api graphql -f query='
    mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: Date!) {
      updateProjectV2ItemFieldValue(input: {
        projectId: $projectId
        itemId: $itemId
        fieldId: $fieldId
        value: {date: $value}
      }) {
        projectV2Item {
          id
        }
      }
    }
  ' -f projectId="$TARGET_PROJECT_ID" -f itemId="$target_item_id" \
    -f fieldId="$END_DATE_FIELD_ID" -f value="$latest_end"
  echo "Updated End Date: $latest_end"
fi

# Statusを更新
if [ -n "$latest_status" ]; then
  gh api graphql -f query='
    mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
      updateProjectV2ItemFieldValue(input: {
        projectId: $projectId
        itemId: $itemId
        fieldId: $fieldId
        value: {singleSelectOptionId: $optionId}
      }) {
        projectV2Item {
          id
        }
      }
    }
  ' -f projectId="$TARGET_PROJECT_ID" -f itemId="$target_item_id" \
    -f fieldId="$STATUS_FIELD_ID" -f optionId="$latest_status"
  echo "Updated Status: $latest_status"
fi

# EPICを更新
if [ -n "$EPIC_FIELD_ID" ]; then
  if [ -n "$latest_epic_name" ]; then
    # EPICが割り当てられている場合
    # ターゲットプロジェクトで同じ名前のオプションを検索
    epic_option_id=$(echo "$target_project_fields" | jq -r --arg name "$latest_epic_name" '
      .data.node.fields.nodes[] | select(.name | type == "string" and (. == "EPIC" or . == "Epic" or . == "epic")) | .options[] | select(.name == $name) | .id
    ' | head -1)
    
    # オプションが存在しない場合は新規作成
    if [ -z "$epic_option_id" ]; then
      echo "Creating new EPIC option: $latest_epic_name"
      
      # 既存のオプション一覧を取得(idも含める)
      existing_options=$(echo "$target_project_fields" | jq -c '
        [.data.node.fields.nodes[] | select(.name | type == "string" and (. == "EPIC" or . == "Epic" or . == "epic")) | .options[] | {id: .id, name: .name, color: "GRAY", description: ""}]
      ')
      
      # 新しいオプションを追加
      updated_options=$(echo "$existing_options" | jq -c --arg name "$latest_epic_name" '
        . + [{name: $name, color: "GRAY", description: ""}]
      ')
      
      # GraphQLリクエストペイロードを作成
      cat > /tmp/epic_update_payload.json <<EOF
{
  "query": "mutation(\$fieldId: ID!, \$options: [ProjectV2SingleSelectFieldOptionInput!]!) { updateProjectV2Field(input: { fieldId: \$fieldId, singleSelectOptions: \$options }) { projectV2Field { ... on ProjectV2SingleSelectField { id options { id name } } } } }",
  "variables": {
    "fieldId": "$EPIC_FIELD_ID",
    "options": $updated_options
  }
}
EOF
      
      # フィールドを更新
      update_result=$(gh api graphql --input /tmp/epic_update_payload.json)
      
      # 新しく作成されたオプションのIDを取得
      epic_option_id=$(echo "$update_result" | jq -r --arg name "$latest_epic_name" '
        .data.updateProjectV2Field.projectV2Field.options[] | select(.name == $name) | .id
      ')
    fi
    
    # EPICを更新
    if [ -n "$epic_option_id" ]; then
      gh api graphql -f query='
        mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
          updateProjectV2ItemFieldValue(input: {
            projectId: $projectId
            itemId: $itemId
            fieldId: $fieldId
            value: {singleSelectOptionId: $optionId}
          }) {
            projectV2Item {
              id
            }
          }
        }
      ' -f projectId="$TARGET_PROJECT_ID" -f itemId="$target_item_id" \
        -f fieldId="$EPIC_FIELD_ID" -f optionId="$epic_option_id"
      echo "Updated EPIC: $latest_epic_name"
    fi
  else
    # EPICが割り当てられていない場合は"Other"を使用
    echo "No EPIC assigned, using 'Other'"
    epic_option_id=$(echo "$target_project_fields" | jq -r '
      .data.node.fields.nodes[] | select(.name == "EPIC") | .options[] | select(.name == "Other") | .id
    ')
    
    if [ -n "$epic_option_id" ]; then
      gh api graphql -f query='
        mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
          updateProjectV2ItemFieldValue(input: {
            projectId: $projectId
            itemId: $itemId
            fieldId: $fieldId
            value: {singleSelectOptionId: $optionId}
          }) {
            projectV2Item {
              id
            }
          }
        }
      ' -f projectId="$TARGET_PROJECT_ID" -f itemId="$target_item_id" \
        -f fieldId="$EPIC_FIELD_ID" -f optionId="$epic_option_id"
      echo "Updated EPIC: Other"
    fi
  fi
fi

# Teamを更新(プロジェクト名を使用)
if [ -n "$latest_project_name" ] && [ -n "$TEAM_FIELD_ID" ]; then
  # ターゲットプロジェクトで同じ名前のオプションを検索
  team_option_id=$(echo "$target_project_fields" | jq -r --arg name "$latest_project_name" '
    .data.node.fields.nodes[] | select(.name == "Team") | .options[] | select(.name == $name) | .id
  ')
  
  # オプションが存在しない場合は新規作成
  if [ -z "$team_option_id" ]; then
    echo "Creating new Team option: $latest_project_name"
    
    # 既存のオプション一覧を取得(idも含める)
    existing_team_options=$(echo "$target_project_fields" | jq -c '
      [.data.node.fields.nodes[] | select(.name == "Team") | .options[] | {id: .id, name: .name, color: "GRAY", description: ""}]
    ')
    
    # 新しいオプションを追加
    updated_team_options=$(echo "$existing_team_options" | jq -c --arg name "$latest_project_name" '
      . + [{name: $name, color: "GRAY", description: ""}]
    ')
    
    # GraphQLリクエストペイロードを作成
    cat > /tmp/team_update_payload.json <<EOF
{
  "query": "mutation(\$fieldId: ID!, \$options: [ProjectV2SingleSelectFieldOptionInput!]!) { updateProjectV2Field(input: { fieldId: \$fieldId, singleSelectOptions: \$options }) { projectV2Field { ... on ProjectV2SingleSelectField { id options { id name } } } } }",
  "variables": {
    "fieldId": "$TEAM_FIELD_ID",
    "options": $updated_team_options
  }
}
EOF
    
    # フィールドを更新
    team_update_result=$(gh api graphql --input /tmp/team_update_payload.json)
    
    # 新しく作成されたオプションのIDを取得
    team_option_id=$(echo "$team_update_result" | jq -r --arg name "$latest_project_name" '
      .data.updateProjectV2Field.projectV2Field.options[] | select(.name == $name) | .id
    ')
  fi
  
  # Teamを更新
  if [ -n "$team_option_id" ]; then
    gh api graphql -f query='
      mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
        updateProjectV2ItemFieldValue(input: {
          projectId: $projectId
          itemId: $itemId
          fieldId: $fieldId
          value: {singleSelectOptionId: $optionId}
        }) {
          projectV2Item {
            id
          }
        }
      }
    ' -f projectId="$TARGET_PROJECT_ID" -f itemId="$target_item_id" \
      -f fieldId="$TEAM_FIELD_ID" -f optionId="$team_option_id"
    echo "Updated Team: $latest_project_name"
  fi
fi

echo "Date and status sync completed for issue: $ISSUE_ID"

6. クリーンアップスクリプト本体

最後に、定期的に選択肢を掃除するスクリプトです。
現在Projectに存在している全Issueをスキャンし、「実際に使われているEpicオプション」を特定します。そして、「全オプション」から「使われているオプション」を引き算し、不要なものを削除(正確には、使用中のものだけでフィールド定義を上書き更新)します。

.github/scripts/cleanup-unused-epic-options.sh
#!/bin/bash
set -e

PROJECT_ID=$1
DRY_RUN=${2:-false}

if [ -z "$PROJECT_ID" ]; then
  echo "Usage: $0 <PROJECT_ID> [DRY_RUN]"
  exit 1
fi

echo "Starting Epic options cleanup for project: $PROJECT_ID"
echo "Dry run mode: $DRY_RUN"

# プロジェクトのEpicフィールド情報を取得
project_fields=$(gh api graphql -f query='
  query($projectId: ID!) {
    node(id: $projectId) {
      ... on ProjectV2 {
        fields(first: 20) {
          nodes {
            ... on ProjectV2SingleSelectField {
              id
              name
              options {
                id
                name
              }
            }
          }
        }
      }
    }
  }
' -f projectId="$PROJECT_ID")

EPIC_FIELD_ID=$(echo "$project_fields" | jq -r '
  .data.node.fields.nodes[] | select(.name | type == "string" and (. == "EPIC" or . == "Epic" or . == "epic")) | .id
')

if [ -z "$EPIC_FIELD_ID" ]; then
  echo "Epic field not found in project"
  exit 1
fi

echo "Epic field ID: $EPIC_FIELD_ID"

# 全Epicオプションを取得
all_epic_options=$(echo "$project_fields" | jq -c '
  [.data.node.fields.nodes[] | select(.name | type == "string" and (. == "EPIC" or . == "Epic" or . == "epic")) | .options[] | {id: .id, name: .name}]
')

echo "Total Epic options: $(echo "$all_epic_options" | jq 'length')"

# プロジェクト内の全アイテムから使用中のEpicオプション名を収集
echo "Collecting used Epic options from project items..."
used_epic_names=$(gh api graphql -f query='
  query($projectId: ID!) {
    node(id: $projectId) {
      ... on ProjectV2 {
        items(first: 100) {
          pageInfo {
            hasNextPage
            endCursor
          }
          nodes {
            content {
              ... on Issue {
                state
              }
            }
            fieldValues(first: 20) {
              nodes {
                ... on ProjectV2ItemFieldSingleSelectValue {
                  field {
                    ... on ProjectV2SingleSelectField {
                      name
                    }
                  }
                  name
                }
              }
            }
          }
        }
      }
    }
  }
' -f projectId="$PROJECT_ID" | jq -r '
  [.data.node.items.nodes[] | 
   select(.content.state == "OPEN") | 
   .fieldValues.nodes[] | 
   select(.field.name | type == "string" and (. == "EPIC" or . == "Epic" or . == "epic")) | 
   .name] | unique
')

echo "Used Epic options: $(echo "$used_epic_names" | jq 'length')"

# 未使用のオプションを特定("Other"は除外)
unused_options=$(echo "$all_epic_options" | jq -c --argjson used "$used_epic_names" '
  [.[] | select(.name != "Other" and ([.name] | inside($used) | not))]
')

unused_count=$(echo "$unused_options" | jq 'length')
echo "Unused Epic options (excluding 'Other'): $unused_count"

if [ "$unused_count" -eq 0 ]; then
  echo "No unused Epic options to clean up"
  exit 0
fi

# 未使用オプションをリスト表示
echo ""
echo "Unused Epic options to be removed:"
echo "$unused_options" | jq -r '.[] | "  - \(.name)"'

if [ "$DRY_RUN" = "true" ]; then
  echo ""
  echo "DRY RUN: No options were deleted"
  exit 0
fi

# 使用中のオプションのみを残す
remaining_options=$(echo "$all_epic_options" | jq -c --argjson used "$used_epic_names" '
  [.[] | select(.name == "Other" or ([.name] | inside($used)))]
')

echo ""
echo "Updating Epic field with remaining options..."

# GraphQLリクエストペイロードを作成
cat > /tmp/epic_cleanup_payload.json <<EOF
{
  "query": "mutation(\$fieldId: ID!, \$options: [ProjectV2SingleSelectFieldOptionInput!]!) { updateProjectV2Field(input: { fieldId: \$fieldId, singleSelectOptions: \$options }) { projectV2Field { ... on ProjectV2SingleSelectField { id options { id name } } } } }",
  "variables": {
    "fieldId": "$EPIC_FIELD_ID",
    "options": $remaining_options
  }
}
EOF

# フィールドを更新
result=$(gh api graphql --input /tmp/epic_cleanup_payload.json)

new_option_count=$(echo "$result" | jq '.data.updateProjectV2Field.projectV2Field.options | length')

echo "Epic options cleanup completed"
echo "Removed: $unused_count options"
echo "Remaining: $new_option_count options"

集約されたMaster Projectの活用(View構成)

5つのViewを作成しています。

  1. Gant By members (EPIC)
  2. Gant By Members (parent Issue)
  3. Gant of My Task
  4. Table of My Task
  5. List All

苦労してAPIでデータを同期させたことで、Master Project上では「全チームのタスク」が横断的に見えるようになりました。
現在、主に以下の5つのViewを用意して運用しています。

image.png

1. Gantt By members (EPIC)

メンバーごとの負荷状況(EPICでのグルーピング)
メンバーでグルーピングし、タスクをEpic単位で表示したガントチャートです。
「誰がどのEpic(大きな機能開発など)をいつまで抱えているか」がひと目でわかります。兼務しているメンバーのスケジュール重複が最も発見しやすいViewです。

設定内容は以下です

  • ViewType: Roadmap
  • Group by: Epic
  • Sort by: End Date
  • Dates: Start Date and End Date
  • Slice By Assigness

2. Gantt By Members (parent Issue)

メンバーごとの負荷状況(Parent Issueでのグルーピング)
チームごとに親子管理の形が違うため Parent Issueごとに確認したい人用で別途作成しています。
具体的な実装タスクレベルで、無理なスケジュールが組まれていないかを確認するために使用します。

設定内容は以下です

  • ViewType: Roadmap
  • Group by: Parent Issue
  • Sort by: End Date
  • Dates: Start Date and End Date
  • Slice By Assigness

3. Gantt of My Task

【個人向け】自分の全タスク俯瞰

個人的な押しポイントはここで、個人のTodo Listとしても使える点です。
今までは手元にあるタスクをメンバーそれぞれが自分の使いやすい形のtoolや方式を使ってタスク管理を行っていたのですが、集約されたことによってここを確認すればよくなりました。

またProjectsの標準機能で issue 自体をcloseにすれば登録されている全ProjectsがDoneになってくれるので手動での変更も不要です。

フィルターで @me を設定した、自分専用のガントチャートです。
チームAのタスクもチームBのタスクも全てここに表示されるため、個人のスケジュール管理はこれを見るだけで完結します。

設定内容は以下です

  • ViewType: Roadmap
  • Group by: EPIC
  • Sort by: End Date
  • Dates: Start Date and End Date
  • Slice By: Team

4. Table of My Task

【個人向け】日々のToDoリスト
「Gantt of My Task」のテーブル(リスト)表示版です。
朝会や日々の作業時に、「今日やるべきこと」「Statusの更新」を行うための作業用Viewです。

設定内容は以下です

  • ViewType: Board
  • Fields: Title, Assigness, Status, Linked Pull Request, Sub-issues progress
  • Column by: Status
  • Swimlanes: Epic
  • Slice By: Team

5. List All

【全体管理用】マスターデータ
フィルターなしの全件リストです。
過去のタスクの検索や、同期漏れがないかの確認、全体の統計情報を確認する際に使用します。

設定内容は以下です

  • ViewType: Table
  • Fields: Title, Assigness, Status, Linked Pull Request, Sub-issues progress

運用してみた結果

良かったこと

  • 「見えない負荷」が可視化された: Master Projectを見るだけで、「Aさん、今週めちゃくちゃタスク重なってない?」と気づけるようになりました
  • 現場の負担ゼロ: 各チームは今まで通り自分たちのプロジェクトを触るだけで良いため、導入への抵抗感がありませんでした
  • Jira時代の負債を解消: 長年の課題だった「横断的なリソース管理」を、モダンな方法で解決できました

本当であれば Master projectsのスクショを貼ってもう少しイメージのしやすい形にまとめたいのですが、どうとっても公開してはダメな情報が移っちゃうので諦めました。
どうなるか気になる方は自分で試していただけると幸いです。

今後の展望と反省

  • 現在は定期実行(ポーリング)ベースですが、Issue数が数千件を超えるとAPIレートリミットや実行時間が課題になる可能性があります。今後は「変更があったIssueのみを検知して同期する」イベント駆動型への最適化を検討しています
  • 専用リポジトリのみのものしか自動で登録できないので可能であれば Team Projects に登録されている別リポジトリのIssueも自動で取得できるようにしたいです
  • ParentIssue機能がなかった時代の名残でEPICというSingle SelectのFieldで管理する文化が定着してしまっていますが、間違いなくParent Issueでの管理の方がよいと思っています。そのためここはParent issueで管理しようねの文化を定着させ直す所存です
    • 例えば、一つのEPIC(ParentIssue)には 設計、実装、レビュー、テスト という決まったサブタスクが紐づくとなった際にEPICごとでこれを自動作成するのは難しいですが、ParentIssueであれば actionsで簡単に自動化できちゃいます
  • 結局のところチームごとに細かくステータス管理しているところと、そこまで細かく更新していないとろとあるのですが、後者のチームに所属しているメンバーは「実はタスクたくさん持ってるんだけど可視化されていない」 という課題が新たに生まれています。前提としてこれを実現するために新たに負荷がかかるメンバーが生まれてしまうのは、これを作成した思想に反するので、どう解決したものかと頭を悩ませている次第です(もしよい解決方法があればコメントで教えてください。)

最後に

弊社で働き始めてからずっと「自分・チームのタスクを管理するという関心事だけでステークホルダーに自分やチームメンバーのリソース状況が共有されないかな」という思いがありました。

これを実現するために、おれおれPMアプリケーションを作ってやろうと思ったのは数知れずでしたが、なかなか業務の片手間だとモチベも胆力も続かず何度も挫折しました。

が、今回github actionsを作成するだけでこの課題が解決できてとても満足しております。(ほとんどcoding Agentに書いてもらったので、手間もほぼかかっていないです。)

GitHub Projects V2 はシンプルに扱えて超便利です。日々のPM業務においての自動化のすべもたくさんあって、今回の記事とはまた別のアプローチが無数にあると思っています。

もし、チームのタスク管理業務で日々の時間が圧迫されている方がいればぜひGithubProjectsの導入の検討をお勧めします。

結論:自動化最高

あとがき

この記事はほとんどGeminiに書いてもらったおかげで1時間ほどでこの分量の記事が書きあがりました。文章力がなくてもある程度読める記事が書ける世界戦ってすごいなとしみじみ思っております。

11
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?