🔗

GitHub IssueをNotion DBに自動同期する — ハマりポイントも含めて整理

に公開

背景

個人開発のタスク管理を GitHub Issues でやっているが、Notion 側でもステータスを確認したくなった。毎回 GitHub を開くのが面倒なので、Issue の作成・更新・クローズを Notion に自動で反映する仕組みを GitHub Actions と Notion API で構築してみた。

参考にした記事はこちら。

https://zenn.dev/utahka/articles/a953af65069a5c

この記事でやることは同じだが、実際に導入してみるといくつかハマりポイントがあったので、それも含めて整理する。

前提条件

項目 備考
GitHub リポジトリ Issues が有効になっていること
Notion アカウント ワークスペースの管理者権限があること
Node.js 既存 Issue の一括インポートスクリプト実行に必要
GitHub CLI (gh) 一括インポートスクリプトが内部で gh issue list を呼び出すため必須

GitHub CLI は未インストールの場合は以下でセットアップする。

# macOS
brew install gh

# 認証
gh auth login

GitHub Actions ワークフロー自体はクラウド上で動くため、ローカルの Node.js や gh は不要。一括インポートスクリプトを手元で実行するときにのみ必要になる。


全体の構成

Issue 作成/更新/クローズ

GitHub Actions(issues イベント)

Notion API で DB を upsert

Issue 番号でページを検索し、なければ新規作成・あれば更新(upsert)する。これで重複を防ぐ。


Step 1: Notion の準備

データベースを作成する

Notion に新しいページを作り、「データベース > テーブルビュー」を選ぶ。
以下のプロパティを追加する。

プロパティ名 タイプ 用途
Title Title Issue タイトル(デフォルトを流用)
Status Select Open / Closed
GitHub URL URL Issue へのリンク
Issue Number Number upsert の照合キー
Labels Multi-select ラベル
Assignee Rich text 担当者
Created At Date Issue 作成日
Milestone Select マイルストーン

Notion データベースにプロパティを追加した状態

Integration を作成する

https://www.notion.so/my-integrations を開き、「新しいインテグレーション」を作成する。

Notion インテグレーション新規作成画面でコネクト名を入力している

作成後に表示される API キー(ntn_xxxx 形式)が NOTION_TOKEN になる。

インテグレーション作成後にアクセストークンが表示される画面

データベースを Integration に接続する

データベースページ右上の「...」→「接続」から作成した Integration を選択する。
これをやらないと API から object_not_found エラーが返ってくる(後述)。

Notion データベースのメニューから接続を選択して Integration を追加している

DATABASE_ID を取得する

データベースの「コピーリンク」を取得すると以下のような URL になる。

https://www.notion.so/myworkspace/abc123def456...?v=xxx000yyy111...
                                  ^^^^^^^^^^^^^^^^
                                  これが DATABASE_ID(?v= の前)

Step 2: GitHub Secrets に登録する

リポジトリの Settings → Secrets and variables → Actions から以下を登録する。

Name Value
NOTION_TOKEN Step 1 で取得した ntn_xxxx
NOTION_DATABASE_ID Step 1 で取得した DATABASE_ID

GitHub リポジトリの Actions Secrets に NOTION_TOKEN と NOTION_DATABASE_ID が登録されている


Step 3: GitHub Actions ワークフローを作成する

.github/workflows/sync-issues-to-notion.yml を作成する。

name: Sync Issues to Notion

on:
  issues:
    types: [opened, edited, closed, reopened, labeled, unlabeled, assigned]

jobs:
  sync:
    runs-on: ubuntu-latest
    concurrency:
      group: notion-sync-issue-${{ github.event.issue.number }}
      cancel-in-progress: false
    steps:
      - name: Sync to Notion
        uses: actions/github-script@v7
        env:
          NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
          NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }}
        with:
          script: |
            const issue = context.payload.issue;

            const properties = {
              "Title": {
                title: [{ text: { content: issue.title } }]
              },
              "Status": {
                select: { name: issue.state === 'open' ? 'Open' : 'Closed' }
              },
              "GitHub URL": {
                url: issue.html_url
              },
              "Issue Number": {
                number: issue.number
              },
              "Labels": {
                multi_select: issue.labels.map(l => ({ name: l.name }))
              },
              "Assignee": {
                rich_text: [{ text: { content: issue.assignee?.login ?? '' } }]
              },
              "Created At": {
                date: { start: issue.created_at }
              },
              "Milestone": {
                select: issue.milestone ? { name: issue.milestone.title } : null
              }
            };

            const headers = {
              'Authorization': `Bearer ${process.env.NOTION_TOKEN}`,
              'Notion-Version': '2022-06-28',
              'Content-Type': 'application/json'
            };

            // 既存エントリを検索(Issue 番号で一意判定)
            const searchRes = await fetch(
              `https://api.notion.com/v1/databases/${process.env.NOTION_DATABASE_ID}/query`,
              {
                method: 'POST',
                headers,
                body: JSON.stringify({
                  filter: {
                    property: 'Issue Number',
                    number: { equals: issue.number }
                  }
                })
              }
            );

            if (!searchRes.ok) {
              core.setFailed(`Notion search failed: ${await searchRes.text()}`);
              return;
            }

            const searchData = await searchRes.json();

            if (searchData.results.length > 0) {
              const pageId = searchData.results[0].id;
              const updateRes = await fetch(`https://api.notion.com/v1/pages/${pageId}`, {
                method: 'PATCH',
                headers,
                body: JSON.stringify({ properties })
              });
              if (!updateRes.ok) {
                core.setFailed(`Notion update failed: ${await updateRes.text()}`);
              }
            } else {
              const createRes = await fetch('https://api.notion.com/v1/pages', {
                method: 'POST',
                headers,
                body: JSON.stringify({
                  parent: { database_id: process.env.NOTION_DATABASE_ID },
                  properties
                })
              });
              if (!createRes.ok) {
                core.setFailed(`Notion create failed: ${await createRes.text()}`);
              }
            }

concurrency の設定について

concurrency を設定している理由は、ラベル付きで Issue を作成すると openedlabeled が同時に発火するため

concurrency なしで試したところ、両方のワークフローが「まだページが存在しない」と判断して同時に新規作成してしまい、Notion DB に同じ Issue が2件登録された。cancel-in-progress: false で先発ジョブを待ってから後発ジョブを実行するようにすると、2回目の実行時には1回目が作ったページが見つかって update になる。

動作確認

ワークフローファイルを main にマージしたら、以下の手順で動作確認する。

1. テスト Issue を作成する

GitHub リポジトリの Issues タブから任意のタイトルで Issue を作成する。ラベルも一緒に付けると openedlabeled の2イベントが発火し、concurrency が正しく機能しているかも同時に確認できる。

2. Actions タブでワークフローの実行を確認する

リポジトリの Actions タブを開き、「Sync Issues to Notion」ワークフローが起動していることを確認する。ラベル付きで作成した場合は2回分のジョブが並んで表示され、1件目が完了してから2件目が実行される。

ジョブが緑(✅)になれば成功。赤(❌)の場合はログを開いて core.setFailed のメッセージを確認する。よくある原因は Secrets の設定漏れや DATABASE_ID の誤りなど。

3. Notion DB にエントリが追加されていることを確認する

Notion データベースを開き、テスト Issue のエントリが1件だけ追加されていることを確認する。ラベル付きで作成しても 2件になっていないことconcurrency が効いている証拠)を必ずチェックする。

4. Issue をクローズして Status が更新されることを確認する

テスト Issue を Close すると closed イベントが発火してワークフローが再実行される。Notion DB の Status カラムが OpenClosed に更新されていれば完成。


Step 4: 既存 Issue を一括インポートする

GitHub Actions は新規イベントのみを拾うので、すでにある Issue は別途スクリプトで同期する必要がある。

scripts/import-issues-to-notion.js を作成する。

#!/usr/bin/env node

const { execSync } = require("child_process");

const NOTION_TOKEN = process.env.NOTION_TOKEN;
const NOTION_DATABASE_ID = process.env.NOTION_DATABASE_ID;

if (!NOTION_TOKEN || !NOTION_DATABASE_ID) {
  console.error("NOTION_TOKEN と NOTION_DATABASE_ID を環境変数にセットしてください");
  process.exit(1);
}

const headers = {
  Authorization: `Bearer ${NOTION_TOKEN}`,
  "Notion-Version": "2022-06-28",
  "Content-Type": "application/json",
};

async function upsertIssue(issue) {
  const properties = {
    Title: { title: [{ text: { content: issue.title } }] },
    Status: { select: { name: issue.state === "OPEN" ? "Open" : "Closed" } },
    "GitHub URL": { url: issue.url },
    "Issue Number": { number: issue.number },
    Labels: { multi_select: (issue.labels ?? []).map((l) => ({ name: l.name })) },
    Assignee: { rich_text: [{ text: { content: issue.assignees?.[0]?.login ?? "" } }] },
    "Created At": { date: { start: issue.createdAt } },
    Milestone: { select: issue.milestone ? { name: issue.milestone.title } : null },
  };

  const searchRes = await fetch(
    `https://api.notion.com/v1/databases/${NOTION_DATABASE_ID}/query`,
    {
      method: "POST",
      headers,
      body: JSON.stringify({
        filter: { property: "Issue Number", number: { equals: issue.number } },
      }),
    }
  );
  const searchData = await searchRes.json();

  if (searchData.results?.length > 0) {
    const pageId = searchData.results[0].id;
    await fetch(`https://api.notion.com/v1/pages/${pageId}`, {
      method: "PATCH",
      headers,
      body: JSON.stringify({ properties }),
    });
    console.log(`Updated: #${issue.number} ${issue.title}`);
  } else {
    await fetch("https://api.notion.com/v1/pages", {
      method: "POST",
      headers,
      body: JSON.stringify({ parent: { database_id: NOTION_DATABASE_ID }, properties }),
    });
    console.log(`Created: #${issue.number} ${issue.title}`);
  }
}

async function main() {
  console.log("GitHub Issue を取得中...");
  const raw = execSync(
    "gh issue list --limit 500 --state all --json number,title,state,labels,assignees,createdAt,url,milestone",
    { encoding: "utf8" }
  );
  const issues = JSON.parse(raw);
  console.log(`${issues.length} 件の Issue を Notion に同期します`);

  for (const issue of issues) {
    await upsertIssue(issue);
    await new Promise((r) => setTimeout(r, 350)); // レート制限対策
  }
  console.log("完了");
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

以下のコマンドで実行する。

NOTION_TOKEN=ntn_xxxx \
NOTION_DATABASE_ID=<DATABASE_ID> \
node scripts/import-issues-to-notion.js

ログに Created: #1 ... と流れていけば成功。

% NOTION_TOKEN=ntn_xxxxx \
NOTION_DATABASE_ID=xxxxx \
node scripts/import-issues-to-notion.js
GitHub Issueを取得中...
18 件のIssueをNotionに同期します
Created: #23 chore: ...
Created: #17 chore: ...
Created: #16 chore: ...
...
Created: #1 ...
完了

Notionデータベース上にも同期されていればOK。

GitHub Issues の内容が Notion データベースに同期されている


ハマりポイントまとめ

1. DATABASE_ID と VIEW_ID を混同した

コピーリンクの URL にある ?v= の後ろは VIEW_ID。?v= の前の部分が DATABASE_ID。間違えると object_not_found が返る。

2. Integration への接続を忘れた

データベースページで Integration と接続しないと、トークンが正しくても object_not_found になる。

3. 重複登録が発生した

ラベル付きで Issue を作成すると openedlabeled が同時に飛ぶ。concurrency なしだと2件作成される。Issue 番号単位の concurrency グループで直列化することで解決した。

4. Actions が develop では動かなかった

issues イベントはデフォルトブランチ(main)のワークフローのみ実行される。main にマージするまではテストできない。


まとめ

  • Notion 側: データベース作成 → Integration 作成 → 接続 → DATABASE_ID 取得(VIEW_ID と混同注意)
  • GitHub 側: Secrets 登録 → ワークフロー作成 → main にマージして有効化
  • 一括インポート: gh issue list --json + Node.js スクリプトで既存 Issue を同期
  • 重複防止: concurrency グループで Issue 番号単位の直列実行

導入後は Issue を作成・クローズするだけで Notion が自動更新されるようになり、Project 管理ツールとして Notion を使いながら開発フローを崩さずに済んでいる。

Discussion