GitHub IssueをNotion DBに自動同期する — ハマりポイントも含めて整理
背景
個人開発のタスク管理を GitHub Issues でやっているが、Notion 側でもステータスを確認したくなった。毎回 GitHub を開くのが面倒なので、Issue の作成・更新・クローズを Notion に自動で反映する仕組みを GitHub Actions と Notion API で構築してみた。
参考にした記事はこちら。
この記事でやることは同じだが、実際に導入してみるといくつかハマりポイントがあったので、それも含めて整理する。
前提条件
| 項目 | 備考 |
|---|---|
| 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 | マイルストーン |

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

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

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

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 |

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 を作成すると opened と labeled が同時に発火するため。
concurrency なしで試したところ、両方のワークフローが「まだページが存在しない」と判断して同時に新規作成してしまい、Notion DB に同じ Issue が2件登録された。cancel-in-progress: false で先発ジョブを待ってから後発ジョブを実行するようにすると、2回目の実行時には1回目が作ったページが見つかって update になる。
動作確認
ワークフローファイルを main にマージしたら、以下の手順で動作確認する。
1. テスト Issue を作成する
GitHub リポジトリの Issues タブから任意のタイトルで Issue を作成する。ラベルも一緒に付けると opened と labeled の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 カラムが Open → Closed に更新されていれば完成。
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。

ハマりポイントまとめ
1. DATABASE_ID と VIEW_ID を混同した
コピーリンクの URL にある ?v= の後ろは VIEW_ID。?v= の前の部分が DATABASE_ID。間違えると object_not_found が返る。
2. Integration への接続を忘れた
データベースページで Integration と接続しないと、トークンが正しくても object_not_found になる。
3. 重複登録が発生した
ラベル付きで Issue を作成すると opened と labeled が同時に飛ぶ。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