bambiiiino
GitHub IssueをNotion DBに自動同期する — ハマりポイントも含めて整理
2026年05月06日
bambiiiino
2026年05月06日
はじめまして、banbiiiinoと申します! IT企業でインフラエンジニア(クラウドエンジニア)として5年ほど働いており、メインはAWSを用いたインフラ環境構築をしています。 【好きなこと・趣味】 ジャズ たまにジャムセッションやスタジオでトランペットを吹いています ボルダリング 最近始めてすっかりハマっています。現在はV1〜V2レベルの課題を練習中です 個人開発 独学でプログラミングを学びながら、さまざまなことにチャレンジしています 【意気込み・学びたいこと】 インフラの知識を活かしつつ、個人開発を通して「こういうのがあったらいいな」というアイデアを自分で形にしていきたいと思っています。フロントエンドやデザインはまだ学んでいる途中ですが、アウトプットを重ねながら着実に成長していきたいです。よろしくお願いします!
見出しはありません
要約を生成中...
個人開発のタスク管理を 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)する。これで重複を防ぐ。
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 | マイルストーン |

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

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

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

データベースの「コピーリンク」を取得すると以下のような URL になる。
https://www.notion.so/myworkspace/abc123def456...?v=xxx000yyy111...
^^^^^^^^^^^^^^^^
これが DATABASE_ID(?v= の前)
?v= の後ろは ビュー ID であり DATABASE_ID ではない。ここを間違えると API が object_not_found を返す。
リポジトリの Settings → Secrets and variables → Actions から以下を登録する。
| Name | Value |
|---|---|
NOTION_TOKEN | Step 1 で取得した ntn_xxxx |
NOTION_DATABASE_ID | Step 1 で取得した DATABASE_ID |

.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'
};
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 を設定している理由は、ラベル付きで Issue を作成すると opened と labeled が同時に発火するため。
concurrency なしで試したところ、両方のワークフローが「まだページが存在しない」と判断して同時に新規作成してしまい、Notion DB に同じ Issue が2件登録された。cancel-in-progress: false で先発ジョブを待ってから後発ジョブを実行するようにすると、2回目の実行時には1回目が作ったページが見つかって update になる。
issues イベントのワークフローは デフォルトブランチ(main)に置かれたファイルのみ実行される。develop ブランチにコミットしても動作しない。
ワークフローファイルを main にマージしたら、以下の手順で動作確認する。
1. テスト Issue を作成する
GitHub リポジトリの Issues タブから任意のタイトルで Issue を作成する。ラベルも一緒に付けると opened と labeled の2イベントが発火し、concurrency が正しく機能しているかも同時に確認できる。
2. Actions タブでワークフローの実行を確認する
リポジトリの Actions タブを開き、「Sync Issues to Notion」ワークフローが起動していることを確認する。ラベル付きで作成した場合は2回分のジョブが並んで表示され、1件目が完了してから2件目が実行される。ジョブが緑(✅)になれば成功。赤(❌)の場合はログを開いて core.setFailed のメッセージを確認する。
3. Notion DB にエントリが追加されていることを確認する
Notion データベースを開き、テスト Issue のエントリが1件だけ追加されていることを確認する。ラベル付きで作成しても 2件になっていないこと(concurrency が効いている証拠)を必ずチェックする。

4. Issue をクローズして Status が更新されることを確認する
テスト Issue を Close すると closed イベントが発火してワークフローが再実行される。Notion DB の Status カラムが Open → Closed に更新されていれば完成。
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 ... と流れていけば成功。
gh issue list --json が返す labels は直接配列なので .nodes でアクセスしないこと。GraphQL の構造と混同すると常に空になる。
コピーリンクの URL にある ?v= の後ろは VIEW_ID。?v= の前の部分が DATABASE_ID。間違えると object_not_found が返る。
データベースページで Integration と接続しないと、トークンが正しくても object_not_found になる。
ラベル付きで Issue を作成すると opened と labeled が同時に飛ぶ。concurrency なしだと2件作成される。Issue 番号単位の concurrency グループで直列化することで解決した。
issues イベントはデフォルトブランチ(main)のワークフローのみ実行される。main にマージするまではテストできない。
main にマージして有効化gh issue list --json + Node.js スクリプトで既存 Issue を同期concurrency グループで Issue 番号単位の直列実行導入後は Issue を作成・クローズするだけで Notion が自動更新されるようになり、Project 管理ツールとして Notion を使いながら開発フローを崩さずに済んでいる。
コメント
まだコメントはありません。