以下の内容はhttps://tech-blog.yayoi-kk.co.jp/entry/2026/03/12/110000より取得しました。


GitHub Actions で Organization の GitHub Project に Issue を自動連携する(カレントイテレーション自動設定)

はじめに

Devin などの AI エージェントで GitHub の Issue を作成すると、プロジェクト(GitHub Projects)への紐づけやイテレーションの設定がうまくいきません。プロジェクトそのものにはアクセスできました。一方で、「現在のイテレーション」をセットするには Organization の read 権限が必要なようです。Devin はボットのためユーザーとして識別されず、自動で付与されません。

そこで、Issue が起票されたタイミングで、GitHub Actions を使ってプロジェクトに追加し、カレントイテレーションを自動で付与するようにしました。その経緯と設定例をまとめます。

起きたこと

Devin で Issue は作れるがプロジェクト連携ができない

  • Devin 経由で Issue は作成できました
  • プロジェクト(GitHub Projects)そのものにはアクセスできました
  • しかし Iteration というプロパティに「いまのカレントイテレーション」をセットできませんでした

原因の整理

  • Organization の GitHub Projects を操作するには、Organization レベルでの read(少なくとも read:project)権限が必要です
  • Devin はボットとして「ユーザー」として識別されていないため、Issue 作成時にプロジェクトのカスタムフィールド(イテレーション)まで自動設定する仕組みが使えません

そのため、「Issue が作成されたら、こちらのワークフローでプロジェクトに追加し、イテレーションを付与する」方針にしました。

解決方針:Issue 作成時に GitHub Actions でプロジェクト+カレントイテレーションを設定

  • トリガーは issues.opened(Issue が作成されたとき)です
  • 処理内容は次のとおりです。対象の Organization の GitHub Project にその Issue をアイテムとして追加します。続けて、プロジェクトの Iteration フィールドに現在のイテレーションをセットします

GitHub の Projects は GraphQL API で操作します。GITHUB_TOKEN はリポジトリスコープのため Projects にアクセスできません。Organization のプロジェクトを触る場合は、GitHub App または PAT(Personal Access Token)が必要です。本番では GitHub App を使う実装にしています。

ワークフローで必要な情報

  1. プロジェクトの Node ID
    • Organization 名とプロジェクト番号から GraphQL で取得します(後述のクエリで取得可能)。
  2. Iteration フィールドの ID
    • プロジェクトのフィールド一覧から「Iteration」の id を取得します。
  3. 「現在のイテレーション」の ID
    • イテレーションフィールドの configuration.iterations に各イテレーションの idstartDate が入っています。今日の日付が含まれるイテレーション、または「現在」に相当する 1 つを選びます。

これらを取得したうえで、次を行います。

  1. addProjectV2ItemById で Issue をプロジェクトに追加します。
  2. updateProjectV2ItemFieldValue で、そのアイテムの Iteration フィールドに iterationId を渡します。

以上の 2 段階で処理します。

使用する設定

  • vars.APP_ID:GitHub App の ID(Actions の Variables)
  • secrets.APP_PRIVATE_KEY:GitHub App の秘密鍵(Actions の Secrets)
  • vars.PROJECT_NUMBER:プロジェクト番号。Settings → Secrets and variables → Actions → Variables で追加します。

APP_IDAPP_PRIVATE_KEY は GitHub App の設定画面から確認できます。App ID は General ページに表示されています。Private Key は同ページ下部の「Generate a private key」から生成します。

コードの説明

ワークフローの全文は下の「ワークフロー全文(クリックで開閉)」に掲載しています。あわせてご覧ください。

  1. トリガーと条件
    issues.opened で起動します。if: github.event.issue.pull_request == null で、Issue だけを対象にし PR は除外します。

  2. GitHub App トークン
    vars.APP_ID が未設定のときは何もしません。設定されていれば actions/create-github-app-token@v2 でインストールアクセストークンを発行し、ownergithub.repository_owner を渡して Organization またはユーザーのプロジェクトにアクセスします。

  3. Issue の Node ID 取得
    repository(owner, name).issue(number) の GraphQL クエリで、対象 Issue の id(Node ID)を取得します。

  4. プロジェクトの Node ID 取得
    まず organization(login).projectV2(number) で Organization プロジェクトを取得します。失敗したら user(login).projectV2(number) でユーザープロジェクトを試します。Org と User の両方に対応しています。

  5. Issue をプロジェクトに追加
    addProjectV2ItemById で Issue をプロジェクトに追加します。既に追加済みの場合は API がエラーを返すことがあるので、エラーメッセージに "already exists" 等が含まれるときはスキップします。その場合、返り値に item.id が含まれないので、次の 6 で既存アイテムを検索します。

  6. 既存アイテムの検索(重複時)
    プロジェクトの items をページングで取得し、content.id が対象 Issue の Node ID と一致するアイテムの id を探します。これで「既にプロジェクトに入っている Issue」にもイテレーションを付けられます。

  7. イテレーションフィールドの取得
    プロジェクトの fields から、ProjectV2IterationField 型で configuration.iterations または configuration.completedIterations を持つフィールドを 1 つ選びます。名前("Sprint" や "イテレーション" など)に依存しないので、フィールド名を変更しても動きます。

  8. 現在イテレーションの決定
    タイムゾーンを JST(Asia/Tokyo)にし、startDateduration(日数)から各イテレーションの終了日を「startDate + (duration - 1) 日」で計算します。今日の日付がその範囲内のイテレーションを「現在イテレーション」とします。iterationscompletedIterations の両方を見るので、GitHub が自動で完了扱いにした直後も正しく選べます。

  9. イテレーションの適用
    updateProjectV2ItemFieldValue で、取得したプロジェクトアイテムのイテレーションフィールドに、上で決めた現在イテレーションの iterationId をセットします。

ワークフロー全文(クリックで開閉)

ワークフロー YAML 全文を表示

# Link new Issues to GitHub Project V2 and set current iteration (sprint).
#
# Required repository configuration:
#   - vars.APP_ID             : GitHub App ID (Actions variable)
#   - secrets.APP_PRIVATE_KEY : GitHub App private key (Actions secret)
#   - vars.PROJECT_NUMBER : Project number, e.g. 68 (Actions variable)
#
# Add PROJECT_NUMBER: Settings → Secrets and variables → Actions → Variables → New repository variable.
name: Link Issue to Project

on:
  issues:
    types: [opened]

permissions:
  contents: read
  issues: write
  repository-projects: write

jobs:
  link-to-project:
    runs-on: ubuntu-22.04-4core
    if: github.event.issue.pull_request == null
    steps:
      - name: Check GitHub App configuration
        id: app-config
        run: |
          if [ -z "${{ vars.APP_ID }}" ]; then
            echo "configured=false" >> "$GITHUB_OUTPUT"
            echo "APP_ID is not set. Skipping Issue-to-Project linking."
          else
            echo "configured=true" >> "$GITHUB_OUTPUT"
          fi

      - name: Generate GitHub App token
        id: app-token
        if: steps.app-config.outputs.configured == 'true'
        uses: actions/create-github-app-token@v2
        with:
          app-id: ${{ vars.APP_ID }}
          private-key: ${{ secrets.APP_PRIVATE_KEY }}
          owner: ${{ github.repository_owner }}

      - name: Add Issue to GitHub Project and set current sprint
        if: steps.app-config.outputs.configured == 'true'
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          PROJECT_NUMBER: ${{ vars.PROJECT_NUMBER }}
          ISSUE_NUMBER: ${{ github.event.issue.number }}
          OWNER: ${{ github.repository_owner }}
          REPO_NAME: ${{ github.event.repository.name }}
        run: |
          set -Eeuo pipefail

          # ─── 1. Validate configuration ───
          if [ -z "$PROJECT_NUMBER" ]; then
            echo "Warning: PROJECT_NUMBER variable is not set. Skipping."
            exit 0
          fi

          echo "=== Linking Issue #$ISSUE_NUMBER to project #$PROJECT_NUMBER ==="

          # ─── 2. Get Issue node ID ───
          ISSUE_NODE_ID=$(gh api graphql \
            -f query='
              query($owner: String!, $repo: String!, $issueNumber: Int!) {
                repository(owner: $owner, name: $repo) {
                  issue(number: $issueNumber) {
                    id
                  }
                }
              }
            ' \
            -f owner="$OWNER" \
            -f repo="$REPO_NAME" \
            -F issueNumber="$ISSUE_NUMBER" \
            --jq '.data.repository.issue.id')

          if [ -z "$ISSUE_NODE_ID" ] || [ "$ISSUE_NODE_ID" = "null" ]; then
            echo "Error: Failed to get Issue node ID"
            exit 1
          fi

          echo "Issue Node ID: $ISSUE_NODE_ID"

          # ─── 3. Get Project node ID (try organization first, then user) ───
          PROJECT_NODE_ID=$(gh api graphql \
            -f query='
              query($owner: String!, $projectNumber: Int!) {
                organization(login: $owner) {
                  projectV2(number: $projectNumber) {
                    id
                  }
                }
              }
            ' \
            -f owner="$OWNER" \
            -F projectNumber="$PROJECT_NUMBER" \
            --jq '.data.organization.projectV2.id' 2>/dev/null || echo "")

          if [ -z "$PROJECT_NODE_ID" ] || [ "$PROJECT_NODE_ID" = "null" ]; then
            PROJECT_NODE_ID=$(gh api graphql \
              -f query='
                query($owner: String!, $projectNumber: Int!) {
                  user(login: $owner) {
                    projectV2(number: $projectNumber) {
                      id
                    }
                  }
                }
              ' \
              -f owner="$OWNER" \
              -F projectNumber="$PROJECT_NUMBER" \
              --jq '.data.user.projectV2.id')
          fi

          if [ -z "$PROJECT_NODE_ID" ] || [ "$PROJECT_NODE_ID" = "null" ]; then
            echo "Error: Failed to get project node ID for project #$PROJECT_NUMBER"
            exit 1
          fi

          echo "Project Node ID: $PROJECT_NODE_ID"

          # ─── 4. Add Issue to project ───
          ADD_RESULT=$(gh api graphql \
            -f query='
              mutation($projectId: ID!, $contentId: ID!) {
                addProjectV2ItemById(input: {
                  projectId: $projectId
                  contentId: $contentId
                }) {
                  item {
                    id
                  }
                }
              }
            ' \
            -f projectId="$PROJECT_NODE_ID" \
            -f contentId="$ISSUE_NODE_ID" 2>&1)

          # Check for errors (allow duplicates)
          if echo "$ADD_RESULT" | jq -e '.errors' > /dev/null 2>&1; then
            ERROR_MSG=$(echo "$ADD_RESULT" | jq -r '.errors[0].message // "Unknown error"')
            if echo "$ERROR_MSG" | grep -qi "already exists\|already added\|duplicate"; then
              echo "Info: Issue #$ISSUE_NUMBER is already in project #$PROJECT_NUMBER"
            else
              echo "Error: Failed to add Issue to project: $ERROR_MSG"
              exit 1
            fi
          fi

          ITEM_ID=$(echo "$ADD_RESULT" | jq -r '.data.addProjectV2ItemById.item.id // empty')

          # When issue is already in project, mutation returns error and no item.id. Look up existing item by content.
          if [ -z "$ITEM_ID" ]; then
            echo "Info: Project item ID not in mutation response (e.g. already in project). Looking up by issue..."
            PAGE_CURSOR=""
            while true; do
              if [ -z "$PAGE_CURSOR" ]; then
                ITEMS_PAYLOAD=$(gh api graphql \
                  -f query='
                    query($projectId: ID!) {
                      node(id: $projectId) {
                        ... on ProjectV2 {
                          items(first: 100) {
                            nodes { id content { ... on Issue { id } } }
                            pageInfo { hasNextPage endCursor }
                          }
                        }
                      }
                    }
                  ' \
                  -f projectId="$PROJECT_NODE_ID" 2>/dev/null || true)
              else
                ITEMS_PAYLOAD=$(gh api graphql \
                  -f query='
                    query($projectId: ID!, $cursor: String!) {
                      node(id: $projectId) {
                        ... on ProjectV2 {
                          items(first: 100, after: $cursor) {
                            nodes { id content { ... on Issue { id } } }
                            pageInfo { hasNextPage endCursor }
                          }
                        }
                      }
                    }
                  ' \
                  -f projectId="$PROJECT_NODE_ID" \
                  -f cursor="$PAGE_CURSOR" 2>/dev/null || true)
              fi
              ITEM_ID=$(echo "$ITEMS_PAYLOAD" | jq -r --arg issueId "$ISSUE_NODE_ID" '
                .data.node.items.nodes[] | select(.content.id == $issueId) | .id
              ' | head -1)
              [ -n "$ITEM_ID" ] && [ "$ITEM_ID" != "null" ] && break
              HAS_NEXT=$(echo "$ITEMS_PAYLOAD" | jq -r '.data.node.items.pageInfo.hasNextPage // false')
              [ "$HAS_NEXT" != "true" ] && break
              PAGE_CURSOR=$(echo "$ITEMS_PAYLOAD" | jq -r '.data.node.items.pageInfo.endCursor // ""')
              [ -z "$PAGE_CURSOR" ] && break
            done
            if [ -z "$ITEM_ID" ] || [ "$ITEM_ID" = "null" ]; then
              echo "Warning: Could not find project item for Issue #$ISSUE_NUMBER. Skipping sprint assignment."
              exit 0
            fi
            echo "Info: Found existing project item ID: $ITEM_ID"
          fi

          echo "Successfully added Issue #$ISSUE_NUMBER to project #$PROJECT_NUMBER"
          echo "Project Item ID: $ITEM_ID"

          # ─── 5. Get Iteration field configuration ───
          # Use fields(first:50) to find iteration field by type, not by name.
          # This avoids issues when the field is renamed (e.g., "Sprint", "スプリント").
          # Also query completedIterations so we can find the current iteration
          # even if it has been auto-completed by GitHub.
          ITERATION_DATA=$(gh api graphql \
            -f query='
              query($projectId: ID!) {
                node(id: $projectId) {
                  ... on ProjectV2 {
                    fields(first: 50) {
                      nodes {
                        ... on ProjectV2IterationField {
                          id
                          name
                          configuration {
                            iterations {
                              startDate
                              id
                              title
                              duration
                            }
                            completedIterations {
                              startDate
                              id
                              title
                              duration
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            ' \
            -f projectId="$PROJECT_NODE_ID")

          # Find the first iteration field: must have configuration.iterations or configuration.completedIterations.
          ITERATION_FIELD=$(echo "$ITERATION_DATA" | jq '
            [.data.node.fields.nodes[] | select(
              .id != null
              and .configuration != null
              and ((.configuration.iterations != null) or (.configuration.completedIterations != null))
            )] | .[0] // null
          ')

          if [ "$ITERATION_FIELD" = "null" ] || [ -z "$ITERATION_FIELD" ]; then
            echo "Warning: No Iteration field found in project. Skipping sprint assignment."
            echo "Debug: Available fields:"
            echo "$ITERATION_DATA" | jq '[.data.node.fields.nodes[] | select(.id != null) | {id, name: (.name // "N/A")}]'
            exit 0
          fi

          ITERATION_FIELD_ID=$(echo "$ITERATION_FIELD" | jq -r '.id')
          ITERATION_FIELD_NAME=$(echo "$ITERATION_FIELD" | jq -r '.name // "Unknown"')

          # Merge active iterations and completed iterations to search both
          ACTIVE_ITERATIONS=$(echo "$ITERATION_FIELD" | jq '.configuration.iterations // []')
          COMPLETED_ITERATIONS=$(echo "$ITERATION_FIELD" | jq '.configuration.completedIterations // []')
          ALL_ITERATIONS=$(echo "$ITERATION_FIELD" | jq '(.configuration.iterations // []) + (.configuration.completedIterations // [])')

          ACTIVE_COUNT=$(echo "$ACTIVE_ITERATIONS" | jq 'length')
          COMPLETED_COUNT=$(echo "$COMPLETED_ITERATIONS" | jq 'length')

          if [ "$ALL_ITERATIONS" = "[]" ] || [ "$ALL_ITERATIONS" = "null" ]; then
            echo "Warning: No iterations found in field '$ITERATION_FIELD_NAME'. Skipping sprint assignment."
            exit 0
          fi

          echo ""
          echo "Iteration Field: '$ITERATION_FIELD_NAME' (ID: $ITERATION_FIELD_ID)"
          echo "Active iterations ($ACTIVE_COUNT):"
          echo "$ACTIVE_ITERATIONS" | jq -r '.[] | "  - \(.title) (Start: \(.startDate | split("T")[0]), Duration: \(.duration) days)"'
          echo "Completed iterations ($COMPLETED_COUNT):"
          echo "$COMPLETED_ITERATIONS" | jq -r '.[] | "  - \(.title) (Start: \(.startDate | split("T")[0]), Duration: \(.duration) days)"'

          # ─── 6. Find current sprint ───
          # Use JST (Asia/Tokyo) so "current" sprint matches Cursor command (apply-current-sprint.sh) and local usage.
          CURRENT_DATE=$(TZ=Asia/Tokyo date +%Y-%m-%d)
          echo ""
          echo "Current date (JST): $CURRENT_DATE"

          # Find the iteration whose date range includes today.
          # For each iteration, compute end date = startDate + (duration - 1) days,
          # then filter those where startDate <= today <= endDate.
          CURRENT_ITERATION=$(echo "$ALL_ITERATIONS" | jq --arg today "$CURRENT_DATE" '
            [ .[] |
              (.startDate | split("T")[0]) as $start |
              (.duration) as $dur |
              ($start | strptime("%Y-%m-%d") | mktime | . + (($dur - 1) * 86400) | strftime("%Y-%m-%d")) as $end_date |
              select($start <= $today and $today <= $end_date) |
              . + {computedEnd: $end_date}
            ]
            | sort_by(.startDate)
            | reverse
            | .[0] // null
          ')

          if [ "$CURRENT_ITERATION" = "null" ] || [ -z "$CURRENT_ITERATION" ]; then
            echo "Warning: No sprint found that includes $CURRENT_DATE."
            echo "Debug: All iterations with computed ranges:"
            echo "$ALL_ITERATIONS" | jq --arg today "$CURRENT_DATE" '
              [ .[] |
                (.startDate | split("T")[0]) as $start |
                (.duration) as $dur |
                ($start | strptime("%Y-%m-%d") | mktime | . + (($dur - 1) * 86400) | strftime("%Y-%m-%d")) as $end_date |
                {title, start: $start, end: $end_date, duration: $dur, includeToday: ($start <= $today and $today <= $end_date)}
              ]'
            exit 0
          fi

          SPRINT_START=$(echo "$CURRENT_ITERATION" | jq -r '.startDate | split("T")[0]')
          SPRINT_END=$(echo "$CURRENT_ITERATION" | jq -r '.computedEnd')
          SPRINT_ID=$(echo "$CURRENT_ITERATION" | jq -r '.id')
          SPRINT_TITLE=$(echo "$CURRENT_ITERATION" | jq -r '.title')

          echo ""
          echo "=== Current Sprint ==="
          echo "Sprint: $SPRINT_TITLE"
          echo "ID: $SPRINT_ID"
          echo "Range: $SPRINT_START ~ $SPRINT_END"

          # ─── 7. Apply current sprint to the project item ───
          echo ""
          echo "Applying sprint '$SPRINT_TITLE' to project item..."

          UPDATE_RESULT=$(gh api graphql \
            -f query='
              mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $iterationId: String!) {
                updateProjectV2ItemFieldValue(input: {
                  projectId: $projectId
                  itemId: $itemId
                  fieldId: $fieldId
                  value: { iterationId: $iterationId }
                }) {
                  projectV2Item {
                    id
                  }
                }
              }
            ' \
            -f projectId="$PROJECT_NODE_ID" \
            -f itemId="$ITEM_ID" \
            -f fieldId="$ITERATION_FIELD_ID" \
            -f iterationId="$SPRINT_ID" 2>&1)

          # Check for GraphQL errors
          if echo "$UPDATE_RESULT" | jq -e '.errors' > /dev/null 2>&1; then
            echo "Error: Failed to apply sprint to project item"
            echo "Response: $UPDATE_RESULT"
            exit 1
          fi

          UPDATED_ITEM_ID=$(echo "$UPDATE_RESULT" | jq -r '.data.updateProjectV2ItemFieldValue.projectV2Item.id // empty')

          if [ -z "$UPDATED_ITEM_ID" ]; then
            echo "Error: Sprint update response did not contain expected project item ID"
            echo "Response: $UPDATE_RESULT"
            exit 1
          fi

          echo "Successfully applied sprint '$SPRINT_TITLE' to Issue #$ISSUE_NUMBER"

注意点

  • GITHUB_TOKEN では Organization の Projects にアクセスできません。そのため GitHub App または PAT が必須です。
  • PAT のスコープは project(read のみなら read:project)が必要です。Organization のプロジェクトを扱うときは、その Organization で PAT が使える状態にしておきます。
  • イテレーションの「現在」の判定は、プロジェクトの設定(週次・月次など)に依存します。上記の jq は例であり、duration や終了日を考慮したロジックに変更しても構いません。

まとめ

  • Devin で Issue は作れますが、プロジェクトの紐づけやイテレーションの付与は権限・識別の都合で自動ではできません。
  • Issue 作成時(issues.opened)に GitHub Actions を動かします。GraphQL でプロジェクトにアイテムを追加し、Iteration フィールドにカレントイテレーションをセットします。Devin が起票した Issue も同じルールで揃えられます。
  • Organization の Projects を触る場合は、GITHUB_TOKEN ではなく GitHub App または PAT(project または read:project)を用意します。

この設定で、Devin が作成した Issue も、手動で作成した Issue も、同じようにプロジェクトのカレントイテレーションに載せて運用できるようになりました。

弥生では一緒に働く仲間を募集しています。
www.yayoi-kk.co.jp
弥生のエンジニアに関する note 記事もご覧ください。
note.yayoi-kk.co.jp




以上の内容はhttps://tech-blog.yayoi-kk.co.jp/entry/2026/03/12/110000より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14