
はじめに
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 を使う実装にしています。
ワークフローで必要な情報
- プロジェクトの Node ID
- Organization 名とプロジェクト番号から GraphQL で取得します(後述のクエリで取得可能)。
- Iteration フィールドの ID
- プロジェクトのフィールド一覧から「Iteration」の
idを取得します。
- プロジェクトのフィールド一覧から「Iteration」の
- 「現在のイテレーション」の ID
- イテレーションフィールドの
configuration.iterationsに各イテレーションのidやstartDateが入っています。今日の日付が含まれるイテレーション、または「現在」に相当する 1 つを選びます。
- イテレーションフィールドの
これらを取得したうえで、次を行います。
addProjectV2ItemByIdで Issue をプロジェクトに追加します。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_ID と APP_PRIVATE_KEY は GitHub App の設定画面から確認できます。App ID は General ページに表示されています。Private Key は同ページ下部の「Generate a private key」から生成します。

コードの説明
ワークフローの全文は下の「ワークフロー全文(クリックで開閉)」に掲載しています。あわせてご覧ください。
トリガーと条件
issues.openedで起動します。if: github.event.issue.pull_request == nullで、Issue だけを対象にし PR は除外します。GitHub App トークン
vars.APP_IDが未設定のときは何もしません。設定されていればactions/create-github-app-token@v2でインストールアクセストークンを発行し、ownerにgithub.repository_ownerを渡して Organization またはユーザーのプロジェクトにアクセスします。Issue の Node ID 取得
repository(owner, name).issue(number)の GraphQL クエリで、対象 Issue のid(Node ID)を取得します。プロジェクトの Node ID 取得
まずorganization(login).projectV2(number)で Organization プロジェクトを取得します。失敗したらuser(login).projectV2(number)でユーザープロジェクトを試します。Org と User の両方に対応しています。Issue をプロジェクトに追加
addProjectV2ItemByIdで Issue をプロジェクトに追加します。既に追加済みの場合は API がエラーを返すことがあるので、エラーメッセージに "already exists" 等が含まれるときはスキップします。その場合、返り値にitem.idが含まれないので、次の 6 で既存アイテムを検索します。既存アイテムの検索(重複時)
プロジェクトのitemsをページングで取得し、content.idが対象 Issue の Node ID と一致するアイテムのidを探します。これで「既にプロジェクトに入っている Issue」にもイテレーションを付けられます。イテレーションフィールドの取得
プロジェクトのfieldsから、ProjectV2IterationField型でconfiguration.iterationsまたはconfiguration.completedIterationsを持つフィールドを 1 つ選びます。名前("Sprint" や "イテレーション" など)に依存しないので、フィールド名を変更しても動きます。現在イテレーションの決定
タイムゾーンを JST(Asia/Tokyo)にし、startDateとduration(日数)から各イテレーションの終了日を「startDate + (duration - 1) 日」で計算します。今日の日付がその範囲内のイテレーションを「現在イテレーション」とします。iterationsとcompletedIterationsの両方を見るので、GitHub が自動で完了扱いにした直後も正しく選べます。イテレーションの適用
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