0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

障害チケットを放置している組織をslack先生で殴って止めた話 ~ n8n × Notion × Slack で「未更新」「未入力」を自動検知するまでを添えて ~

0
Last updated at Posted at 2026-06-16

ラノベ系タイトル目指したら本当にそれっぽい気がしてきたので逆に困る

自己紹介

2026年4月から手動テスト活動を明後日の方向にブン投げてAIと一緒に品質改善することに対して全賭けしているQAエンジニア10歳児です。まだ未成年ってこと。
自動テストはAIと一緒に書いてます。手元確認しながらやってますがそろそろノールックでもいけそうです。
あとスクラムマスター歴5年超えました。資格いつとるべきか……


1. 何を解決したかったか

開発組織として障害(インシデント)を Notion DBで管理しています。
1件のレコードに対して「原因」「影響範囲」「暫定対応」「恒久対応」「再発防止」を埋めるルールになっていて、これらが揃って初めて「振り返り済み」と言える状態になります。

お問い合わせから Notionで障害報告書を自動で作成する機能は前からあり、それをAIと接続して不具合元の開発やバグ修正チケットを探ってプルリクから
「犯人はお前だ!!」
という運用をしている状態です。
(バグ調査時間が4hから5min~30minまで落ちて皆ハッピー)

……が、犯人を特定した先のチケットがそもそも放置されている可能性まで頭が回っていませんでした。
嘘ですごめんなさい、AIが台頭してこなければ費用対効果の観点で調査の優先度ダダ下がりしてました。
ノーコードQAエンジニアには荷が勝ちすぎたのです……
逆に言えば考えていることがAIで実現できるので、私としては良い世の中になりました。


2. 検索条件の設計と調査結果

「放置されている」の定義

まず「放置されている」を定量的に定義しました。

  1. ステータスが完了以外で、14日以上更新されていない
    (ここまで放置は流石にないやろ精神)

  2. 暫定対応・恒久対応・再発防止欄が何も書かれていない、或いは書きかけの状態である
    (ここはまぁまぁありそうかなぁ精神)

  3. そもそも不具合を出した開発チームが記述されていない
    (まぁ流石にないだろガハハ)

調査結果

  • 60件以上該当しました。
  • 「開発チームが空欄」が見つかりました。
  • 実稼働3か月位放置されているチケットがありました。
    うっそだろお前……

改めて調査してみると、実態はこうでした。

  • 古いインシデントが放置される: 2週間以上更新されないまま、進捗が止まる。
  • 必須セクションが未入力のまま閉じられそうになる: 特に「再発防止策」が抜けやすい。
  • 担当チームが多すぎて、誰がどれをやるか追えなくなる: 一覧でただ眺めても情報過多で手が動きにくい。

人がカンバンを眺めて見つけるには限界があるよねって話です。
なので「仕組みでリマインドする」のが現実的だよねぇ、という考えに至りました。
というか大体予想した通りです。

やったこと

この調査結果を踏まえ、以下の仕組みをAIを交えて作ることにしました。

  • 毎週月曜 9:00 JST に Notion DBをスキャン。
  • 以下のどちらかに該当するレコードを抽出:
    • stale: 最終更新から14日以上経過。
    • 未入力あり: 必須セクション5つ (原因 / 影響範囲 / 暫定対応 / 恒久対応 / 再発防止) のいずれかが未入力。
  • 担当チームごとに Slack へサマリ通知。
  • メッセージには Notion ページのリンクと、当時の障害対応 Slack スレッドへのリンクも同梱する。

シンプルですが、Notion本文の構造解析とSlackのBlock Kit制限対応など、地味な詰みポイントが多かったので後述します。


3. なぜn8nを選んだか

  • Notion / Slackの組み合わせは将来も増えそう: 障害管理DB以外にも、PdM / CS 領域のリマインドや、AI要約の社内通知などで横展開が効きそうな設計にしたかった。
  • ノーコード/ローコード基盤が社内に既にあった: 既存の n8n 環境にスケジューラと credential 管理が整備されていて、追加の運用コストがほぼゼロで助かった。

ただ、n8nを選んだ決定打は3つです。

  1. コードを書かない関係者に運用を引き継ぎたい: 後々「フィルタ条件を変えたい」「Slackの文言を直したい」というニーズが必ず出てくるが、アプリの中に閉じ込めると、エンジニア以外が触れられない可能性があった。
  2. 自由度の高い Code ノード:ページの本文ブロックからH1見出しでセクション分け(本文に障害理由や再発防止まで分けられているので)する、みたいな解析は素直に書ける言語が必要だった。
  3. Claudeプランで運用コストがゼロ:インフラ管理が不要で、credential管理やスケジューラも組み込みで揃っている。

実装はAIとの併走で進めました。
AIは何でも良いです。Devin、Claude Code、Cursor 等々で実験してみましたが概ね同じ結果が出ました。
sonnetとかそういうのもあまり関係なかったと思います。
この事から要件や条件を固めておけば出力される結果は変わらないという可能性も小さいながら見えてきました。
とはいえ、まだN=1なので次々試してます。

次の全体アーキテクチャはノーコード組はさっぱわっかんないと思うのでコード部分は読み飛ばして大丈夫です。
AIが全部やってくれますので、仕組みを後でAIに聞きまくりましょう。


4. 全体アーキテクチャ

最終的に出来上がったワークフローはこんな形になりました。

[Schedule: Weekly Mon 09:00 JST]
         ↓
[Query Notion DB]           ← HTTP Request (POST /databases/{id}/query, pagination)
         ↓
[Flatten DB Results]        ← ページネーション応答を展開、ページ単位 1 アイテムに整形
         ↓
[Loop Over Pages] ──────────────────────────────────────────┐
         ↓ (loop output)                                    │
[Get Page Blocks]           ← HTTP Request (GET /blocks/{id}/children)
         ↓                                                  │
[Collect Page Blocks]       ← ブロックを集約し { pageMeta, blocks } へ
         └──────────────────(loop back)──────────────────→──┘
         ↓ (done output)
[Evaluate Pages]            ← H1 セクションを解析、stale/未入力を判定
         ↓
[Build Slack Message]       ← チームでグルーピング、BlockKit 構築
         ↓
[IF has items]              ← 通知対象 0 件ならスキップ
         ↓ true
[Slack: Post Message]       ← HTTP Request (POST chat.postMessage)

10ノード構成。設計上のポイントは大きく3つ。

4.1 Notion API 呼び出しは HTTP Request ノードで実装した

n8nにはNotion専用ノードがあり、最初はそれを使っていました。
が、本ワークフロー固有の事情でハマって、最終的に すべて HTTP Requestノードに置き換えています。

経緯は後述しますが、要するに「Code ノードの中から credentialを使えない」というn8nの仕様制約が刺さって、ロジック層とAPI呼び出し層を分離せざるを得なくなったのです。

↓こんな感じ↓

{
  "filter": {
    "and": [
      { "property": "報告日", "date": { "on_or_after": "2025-10-01" } },
      {
        "or": [
          { "property": "ステータス", "status": { "equals": "未着手" } },
          { "property": "ステータス", "status": { "equals": "原因・影響範囲調査" } },
          { "property": "ステータス", "status": { "equals": "暫定対応" } },
          { "property": "ステータス", "status": { "equals": "恒久対応" } },
          { "property": "ステータス", "status": { "equals": "再発防止設定" } },
          { "property": "ステータス", "status": { "equals": "再発防止対応" } }
        ]
      },
      {
        "or": [
          { "property": "発生元プロダクト", "multi_select": { "contains": "サービス A" } },
          { "property": "発生元プロダクト", "multi_select": { "contains": "サービス B" } }
        ]
      }
    ]
  },
  "page_size": 100
}

ステータスと「発生元プロダクト」(multi_select)の AND / OR入れ子で対象を絞り込みます。

4.2 ページ毎のブロック取得は SplitInBatchesでループ

NotionのDB Queryは1リクエストで最大100件のメタ情報しか返してくれません。
さらに「本文を全文取得したい」場合、各ページに対して GET /v1/blocks/{page_id}/children を別途叩く必要があります。

70件のページがあれば70 回 + ページネーション分のAPI呼び出しが発生するので、これをn8nで素直に書くと SplitInBatches (Loop) で 1 ページずつ回す形になります。

[Flatten DB Results] → [Loop Over Pages] → [Get Page Blocks] → [Collect Page Blocks] → (loop back)
                                                                                          ↓ done
                                                                                       [Evaluate Pages]

ループ内ではループの中だけで使うサブセットのCodeノード (Collect Page Blocks) を置いて、ページネーション込みのblocks取得を集約します。

4.3 Slack 投稿は標準ノードでなくHTTP Requestでchat.postMessage

n8nの標準SlackノードはBlockKitの送信に癖があります。
「Message TypeがSimple Text」のモードで動いてしまうと、せっかく組み立てたblocksが無視されてカウント文字だけが投稿されてしまいます。ハマリポイント……

結局、AIに聞いてSlack Credentialを流用しつつHTTP Requestで chat.postMessage を直接叩く実装にしました。

POST https://slack.com/api/chat.postMessage
Authentication: Predefined Credential Type (Slack API)
Headers: Content-Type: application/json; charset=utf-8
Body:
{
  "channel": "<CHANNEL_ID>",
  "text": {{ JSON.stringify($json.text) }},
  "blocks": {{ JSON.stringify($json.blocks) }}
}

このパターンはn8nの他のワークフローでも「BlockKitを確実に送りたい」場面で再利用できます。


5. 本文解析: H1セクションで「未入力」を判定する

Notionの本文ブロックは、シンプルに APIレスポンスから block.typeblock.<type>.rich_text を見ていくと、構造化はされていますが「見出しごとの境界」は自分で識別する必要があります。

このプロジェクトでは H1 (heading_1) を「セクションの区切り」 と決め打ちにしました。
Notion DB のテンプレートで # 原因 / # 影響範囲 / # 暫定対応 / # 恒久対応 / # 再発防止 が H1 として並ぶ仕様だったからです。

function extractSectionsFromBlocks(blocks) {
  const sections = {};
  let currentSection = null;
  for (const block of blocks) {
    if (block.type === 'heading_1') {
      const heading = richTextToPlain(block.heading_1.rich_text).trim();
      currentSection = heading;
      sections[heading] = { meaningful: false, blocks: [] };
      continue;
    }
    if (currentSection) {
      sections[currentSection].blocks.push(block);
    }
  }
  for (const name of Object.keys(sections)) {
    sections[name].meaningful = hasMeaningfulContent(sections[name].blocks);
  }
  return sections;
}

hasMeaningfulContent は「テンプレートのプレースホルダ (バッククォート付きの注釈) を除いて、5 文字以上のテキストが 1 つでもあるか」で判定しています。
これによって「セクションは作ったけど中身が空 / 例文だけ」のページを「未入力」として検出できるようになりました。

つまずきポイント: ブロックの種類が思ったより多い

最初は paragraph だけを見ていたのですが、現場ではbulleted_list_item, numbered_list_item, toggle, callout, code, quote …とバリエーションが多く、これらすべてから rich_text を抜く必要がありました。

function blockToText(block) {
  const t = block.type;
  const rt = block[t]?.rich_text;
  if (!rt) return '';
  return richTextToPlain(rt);
}

block[block.type] で動的にアクセスする書き方は、Notionブロック仕様にうまく寄せたコードです。


6. Slack メッセージの組み立て: 50 block 制限との戦い

Slack の chat.postMessage には 1メッセージあたり50blocks まで という制限があります。
最初の素朴な実装では「1件のインシデント = 1section block」にしていたので、70件出てきた瞬間に「(表示一部省略)」と切られて、メインの情報がほぼ見えなくなりました。

そこで、AIに聞いて表示単位を再設計しました。

  • チーム単位 = 1 section block に集約 (複数 item を \n 連結)
  • 各 item は 3 行構成
    • 1 行目: • <Notion link|タイトル>
    • 2 行目: 🔗 <Slack スレッド link|障害Slackスレッド> (任意、空のときは省略)
    • 3 行目: 📌 ステータス | ⏰ N日未更新 | ✏️ 未入力: 原因/影響範囲 | ⚠️ 再発防止 要記入
function itemLine(it) {
  const parts = [];
  parts.push(`📌 ${it.status || '-'}`);
  if (it.stale) parts.push(`⏰ ${it.daysStale}日未更新`);
  if (it.missing.length) parts.push(`✏️ 未入力: ${it.missing.join(' / ')}`);
  if (it.recurrenceRequired && it.missing.includes('再発防止')) {
    parts.push('⚠️ 再発防止 要記入');
  }
  const lines = [`• <${it.url}|${it.title}>`];
  if (it.slackUrl) {
    lines.push(`  🔗 <${it.slackUrl}|障害Slackスレッド>`);
  }
  lines.push(`  ${parts.join('')}`);
  return lines.join('\n');
}

これで「2桁チーム × 各チームに 1セクション + ヘッダ / divider」でも block 数が抑えられ、表示崩れがなくなりました。

チームの並び順は「staleが含まれるチーム = 要リマインド」を先頭に出す、そのほかはアルファベット順です。

const sorted = teams.sort((a, b) => {
  if (a.hasStale !== b.hasStale) return a.hasStale ? -1 : 1;
  return a.name.localeCompare(b.name, 'ja');
});

「期限が迫っているものから目に入る」UX を意識してます。


7. 後付けで追加した機能: Slack スレッドリンクとプロダクト除外

Slackスレッドリンクの追加

最初は「Notionページのリンク + メタ情報」の 2 行構成だけで動かしていたのですが、運用してみると 当時の障害対応 Slack スレッドにも 1 クリックで飛びたい というニーズが出てきました。
それはそう。

幸い、障害管理DBにはURLプロパティが既に存在していたので、新規プロパティを追加せずに済みました。
この値があるページだけ2行目を差し込み、ないページはスキップ (空白行を出さない) する、という条件分岐を入れて、見た目を綺麗に保ちました。

「Notion側がもうそのプロパティを運用していた」という事実が事前にあった (= スキーマ追加が不要だった) のは、設計のラッキーな要因です。
逆に言えば、Notion DBは新しい運用要求に対して「とりあえずプロパティを増やしておく」が低コストにできるので、自動化系の組み合わせと相性が良いんだなぁと感心してました。

周辺プロダクトの除外

途中で「対象を特定プロダクトに絞りたい。それ以外のプロダクトは対象外」という要望が出ました。

Notion APIのフィルタで素直に書けますが、multi_selectcontains配列に 1 つでも含まれていれば true なので、複合タグページも通ります。
そこで、クライアントサイド (Code ノード) でも再度フィルタを通す二重フィルタをAIに入れてもらいました。

const TARGET_PRODUCTS = ['サービス A', 'サービス B'];
const STRICT_PRODUCT_FILTER = false; // true: TARGET のみで構成されているページのみ通す

const products = meta.products || [];
if (products.length > 0) {
  const hasTarget = products.some((p) => TARGET_PRODUCTS.includes(p));
  const onlyTarget = products.every((p) => TARGET_PRODUCTS.includes(p));
  const pass = STRICT_PRODUCT_FILTER ? onlyTarget : hasTarget;
  if (!pass) continue;
}

STRICT_PRODUCT_FILTER を切り替えるだけで「複合タグも含める / 排他にする」が選べるって教えて貰えたので、運用の選択肢を残せる作りにしてもらいました。

やらかし:「発生元プロダクト」と「影響プロダクト」を間違えない

Notion DBには似たプロパティが2つあり、最初これを取り違えました。

  • 発生元プロダクト: 障害がそもそも発生したプロダクト (=担当チームが取り組むべき対象)
  • 影響プロダクト: その障害が波及した先 (=二次的影響)

リマインド先を「発生元の担当チーム」に届けたいのか「影響先のチーム」に届けたいのか、ここを明確にしないとフィルタの設計を間違えます。
今回は「発生元」ベースに統一しました。


8. ハマったこと TOP5

実装よりも、外部SaaS連携の「現場のクセ」で時間を溶かしました。
後続のために残しておきます。

8.1 Slack Bot Tokenのワークスペース不一致

初歩的なやらかしですが「テスト用のworkspace」と「本番用のworkspace」が別組織の場合、Bot Tokenは workspace単位で発行されます。
テスト用のworkspaceで生成したTokenで本番用のworkspaceのチャンネルIDを叩く
channel_not_found が返ります。

切り分けは auth.test一発でわかります。

curl -X POST "https://slack.com/api/auth.test" \
  -H "Authorization: Bearer xoxb-****"

teamurl を見ればその Tokenが所属するworkspaceが一発で確定します。

8.2 missing_scope

chat:write を Bot Token Scopes に追加するのを忘れていて、最初は投稿できませんでした。Scope 追加後は Reinstall to Workspace が必須で、その時点で旧 Token は失効するので n8n の Credential を入れ替える必要があります。

n8n の Credential 編集は、保存後の値が __n8n_BLANK_VALUE_... という placeholder で表示されます。
これは「内部的には保存されているが盗み見防止でUIには出さない」だけなので、私みたいに画面の見た目で不安にならないように。初見殺し。

8.3 標準SlackノードがBlockKitを無視する

n8n標準Slackノードは「Message Type: Simple Text」で固定になることがあり、blocks を渡しても無視されました。何で?
HTTP Requestでchat.postMessageを直接叩く方式に変えると確実だよとAI先生から教えられました。

8.4 n8nのJSON Bodyでハマる

Slack: Post Message の JSON Body に式を書く際、ハマりやすいのは

{
  "channel": "...",
  "text": "{{ $json.text }}"  //  これは NG (改行や引用符が壊れる)
}

正しくは

{
  "channel": "...",
  "text": {{ JSON.stringify($json.text) }},
  "blocks": {{ JSON.stringify($json.blocks) }}
}

JSONテンプレート + 値だけJSON.stringify(...) が n8n の標準的な書き方でした。
これを知らないと、改行と絵文字の入った長文をうまく送れません。ぐぎぎ。

8.5 Notion CodeノードにCredential を紐付けできない

n8nのCodeノードは便利ですが、UIからcredential をアタッチできない仕様です。
(環境変数かAPIキーをコードに埋めるしかない)
これを避けるには、API呼び出しをHTTP Requestノードに切り出してcredentialを選ばせる形にする必要があります。

「ロジックはCode。API呼び出しはHTTP Request」という分業がn8nのお作法だと理解した瞬間、設計がぐっと整理できました。


9. 出来上がったメッセージ

最終的にSlackに届くメッセージはこんな感じです (内容はダミー化しています)。

🚨 障害管理DB 週次サマリ (YYYY-MM-DD)
対象: 報告日 YYYY-MM-DD 以降 / ステータス 〜 再発防止対応待ち / 対象プロダクト サービスA・サービスB
全 N 件(2週間未更新 X 件 / 開発側未入力あり Y 件 / 再発防止策 要記入 Z 件)
───────────────
*【チーム X】* — 5 件(要リマインド) <!subteam^SXXXX|@team-x>
• <https://...|障害タイトル A>
  🔗 <https://slack...|障害Slackスレッド>
  📌 恒久対応待ち | ⏰ 21日未更新 | ✏️ 未入力: 再発防止策 | ⚠️ 再発防止策 要記入
• <https://...|障害タイトル B>
  📌 原因・影響範囲調査中 | ✏️ 未入力: 原因 / 影響範囲
...
───────────────
*【チーム Y】* — 3 件
...
  • staleが含まれるチームだけメンションが付く
  • Slackリンクは存在するときだけ表示
  • 未入力セクションが列挙される

あたりが地味に効いてきます。


10. 導入効果と運用のコツ

効果(実装から二週間後)

比較すると前週比較で10%削減されてました。
やはり可視化は全てを解決しますね。

グループメンション管理の注意点

グループメンションを纏めているページを人事やコーポレート等にお願いして作って貰って、相手かAIに更新してもらうようにしましょう。(方法はAIにチャットで聞いてみると良いです)
これはn8nで自動投稿した際にグループメンションが変更されたタイミング次第で、グループメンションが機能しなくなって通知されなくなることを阻止する為です。

※人事労務→コーポレート→AI連携で該当ページに開発チームを含めた全社グループメンション置き場があることが望ましいです。
組織内でのチーム名が変わる、或いはチームが増えると予想できる場合、ここは絶対に譲ってはなりません。
ベタ張りは組織編制のあおりを受けて詰みやすいのでオススメできません。

社内展開のコツ

協力的な上司と開発のマネージャーがいると良いです。
いなければ役員に笑顔で提示しましょう。怒り狂って命令してくれるに違いありません。
「おめでとうございます!! 貴方は品質保証に選ばれました!!」
根回し、私の好きな言葉です。


11. 今後やりたいこと

11.1 チームメンション対応表をNotionDBに外出し

ちょっと前の文でも触れましたが現状はチーム → Slackメンションのマッピングをハードコードしています。
Notionに「チームメンション置き場」を作って、エンジニア以外でも更新できる形に移したい。
ワークフロー側にNotionDB取得ノードを1つ追加するだけで簡単実装できる想定です。

11.2 AIを利用した再発防止関連に手を入れる

再発防止策が未入力 + 再発防止が必要 のレコードに対して、本文 + 関連ソースコードをコンテキストにAIを呼び、それに基づいた再発防止策をSlackスレッドに返信する。
ドラフト精度は完璧でなくていいので、「白紙からよりは書き始めやすい」を狙うイメージです。
つまりレビューのみ人を乗せる策です。

逆に人間が書いた再発防止策を AI にチェッキングさせるパターンとのABテストも予定しています。

※このABテストについては全チームに一斉展開するのではなく、まず受容性の高い1から2チームに相談して始めます。
なるべく早く適切なフィードバックが手に入ることが目的なので。
AIに抵抗がなく、リファインメント等も回っているチームが結果を良きにせよ悪しきにせよ出してくれます。
仕込みとして他チームの飲み会に出入りしとくと楽なのは令和でも変わらず。

11.3 進捗トレンドの可視化

毎週のサマリ件数 (全件 / stale / 未入力 / 再発防止策必要) を別 DB に蓄積して、月次でグラフ化。
「組織として障害管理がどれだけ健全に回っているか」のKPI化を立ち上げ。


12. まとめ

  • Notion DB の鮮度を保つ仕組みは、自動化と相性が非常に良い。プロパティを 1 つ増やすだけで運用フローが洗練される
  • APIのクセ (SlackのBlock制限 / multi_select containsの挙動 / Tokenのworkspace単位 / Scope反映) を抑えれば、難しい技術は使っていない。むしろ「現場のニーズを定式化する」工程の方が重要。
  • 可視化は全てを解決する。60件以上放置されていたチケットが、Slack 週次通知だけで前週比10%削減された。
  • わからなければAIに聞いて実装する。料金値上げの波が来る前に使い倒すべし。

「障害がなるべく起きないようにする」のは開発チームの本質的な仕事です。
ただ「起きた障害の振り返りが滞らないようにする」はQAエンジニアの本質的な仕事です。
後者は仕組みで支えられる領域で、まさに品質改善ともいえます。
なので、面倒くさがらずに自動化を仕込むと組織の体力を地味に底上げできると思います。

小さく作り、小さく伝播させ、小さな結果を求めてください。

千里の道も一歩から。私の好きな言葉です。


付録: 使用した主要技術スタック

  • Devin:壁打ちから実装まで
  • n8n (self-hosted): スケジューラ + ワークフロー基盤
  • Notion API (2022-06-28): DB Query, Block Children 取得
  • Slack API: chat.postMessage (Block Kit), auth.test
  • ノード構成: HTTP Request + Code (JavaScript) + SplitInBatches + IF + Schedule Trigger
ノード数: 10
コード行数: ~400 行 (4 つの Code ノードを合算)
実装期間: 設計と試行錯誤込みで 2日
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?