Slack のやりとりから GitHub Issue を自動作成する Bot を作った
背景
チームで Slack を使って開発していると、会話の中に「この機能を改善したい」「このバグを直してほしい」という話が出てくることがよくあります。ただ、Slack でのやりとりは流れていくので、後で GitHub に Issue を起こそうとすると「あの会話どこだっけ」と探す羽目になります。
そこで、Slack のやりとりをそのまま GitHub Issue に変換できる Bot を作りました。Bot にメンションして「この内容で Issue 作って」と伝えるだけで、AI が内容を整形して Issue を作ってくれます。

確認メッセージにはラベルの提案と類似 Issue の検索結果も表示されます。

作成された GitHub Issue はこのようになります。

機能一覧
@Bot にメンションするだけで、AI が会話を整形して Issue を生成します。会話内容から bug / enhancement / documentation / question のラベルを自動判定し、類似 Issue を GitHub Search API で検索して確認メッセージに表示します。
チャンネルにデフォルトリポジトリを紐付けることで、@Bot set-repo owner/repo でリポジトリ指定が不要になります。Issue 作成後は、スレッドで「クローズ」「再オープン」とメッセージするだけで GitHub Issue の状態を変更できます。
技術構成
ランタイムは Cloudflare Workers(Wrangler)で、Slack Bot のフレームワークに Chat SDK(chat, @chat-adapter/slack)を使いました。AI は Cloudflare Workers AI で動かしており、workers-ai-provider と Vercel AI SDK(ai)の generateText 経由でモデルを呼び出しています。モデルは @cf/qwen/qwen3-30b-a3b-fp8 を使っています。外部サービスの API キーは不要です。GitHub との連携は @octokit/rest で行っています。
会話の状態管理には Durable Objects を使い、Workers が再起動しても状態が失われないようにしています。
Cloudflare Workers を選んだのは、サーバーの管理が不要でデプロイが簡単だからです。AI も Workers AI バインディングを使うため、外部の AI サービスへの API キーは必要ありません。
実装のポイント
Chat SDK で Slack Bot を実装
Chat SDK を使うと、Slack の Webhook 処理や会話の状態管理をまとめて扱えます。onNewMention でメンション時の処理を定義し、thread.post でスレッドに返信します。
chat.onNewMention(async (thread, message) => {
const userId = message.author?.userId ?? "";
const mentionText = message.text ?? "";
// 認可チェック
if (!isAllowedUser(userId, env.ALLOWED_SLACK_USER_IDS)) {
await thread.post(
":no_entry: このボットを使用する権限がありません。管理者にお問い合わせください。"
);
return;
}
// 処理中をリアクションで示す
await thread.adapter.addReaction(thread.id, message.id, "hourglass_flowing_sand");
// ... Issue 作成処理
});
処理中は「確認中です...」というメッセージを投稿する代わりに、ユーザーのメッセージに ⏳ リアクションを付けるようにしました。チャットが増えず見た目がすっきりします。
認可は ALLOWED_SLACK_USER_IDS 環境変数にカンマ区切りで Slack のユーザー ID を設定することで管理しています。
スレッドの会話履歴を取得
チャンネルの直近のメッセージとスレッドのメッセージを組み合わせて AI に渡します。thread.messages は AsyncIterable で返ってくるので for await でイテレートします。
// チャンネルの直近メッセージを取得
const channelResult = await thread.adapter.fetchChannelMessages?.(
`slack:${thread.channelId}`,
{ limit: 20 }
);
// スレッドのメッセージを追加(重複を避ける)
for await (const msg of thread.messages) {
const text = msg.text ?? "";
const author = msg.author?.displayName ?? "unknown";
if (text.trim() && !conversationHistory.some((h) => h.text === text)) {
conversationHistory.push({ author, text });
}
}
Workers AI で Issue 内容を生成
会話履歴を渡して、AI に GitHub Issue のタイトル・本文・ラベルを JSON 形式で出力させます。
const { text } = await generateText({
model,
system:
"あなたは Slack の会話から GitHub Issue を作成する優秀なアシスタントです。必ず有効な JSON のみを返してください。",
prompt: `以下は Slack の会話履歴です。この内容を元に GitHub Issue を作成してください。
## 会話履歴
${conversationText || "(会話履歴なし)"}
## ユーザーからの指示
${mentionText}
## 出力ルール
- 会話履歴が少なくてもユーザーからの指示に何か書かれていれば必ず Issue を作成する
- labels は ["bug", "enhancement", "documentation", "question"] の中から最も適切なものを 1〜2 個選ぶ
- 常に以下の JSON 形式で出力する
## 出力形式
{
"title": "日本語のタイトル",
"body": "日本語の本文(Markdown 形式)",
"labels": ["bug"]
}`,
});
ラベルは固定リストから選ばせることで、リポジトリに存在しないラベルが生成されるリスクを防いでいます。
当初は「会話が空の場合は {"skip": true} を返す」という指示をプロンプトに入れていましたが、一言のメンション(「ログイン画面でエラーが出る」など)に対してモデルがこのパターンを誤って返す問題が発生しました。プロンプトからサンプルを削除し、空チェックをコード側で行うことで解決しました。
メッセージ中のリポジトリを正規表現で抽出
Bot に「kusumotoa/myapp の Issue を作って」と伝えると、正規表現で owner/repo を抽出してリポジトリを特定します。
export function extractRepoFromText(
text: string
): { owner: string; repo: string } | null {
const match = text.match(/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)/);
if (!match) return null;
return { owner: match[1], repo: match[2] };
}
デフォルトリポジトリ設定
毎回 owner/repo を指定するのは手間がかかるため、チャンネルにデフォルトリポジトリを紐付けられるようにしました。
@楠本雑務bot set-repo kusumotoa/myapp

設定後はリポジトリを指定しなくてもメンションするだけで Issue 作成フローが始まります。Chat SDK のチャンネル状態(thread.channel.setState)に保存しているので、スレッドをまたいでも設定が維持されます。
重複チェック
Issue 生成後、GitHub Search API でタイトルに類似した Issue を検索して確認メッセージに一緒に表示します。
const keywords = title
.split(/[\s\u3000]+/)
.filter((w) => w.length >= 2)
.slice(0, 5)
.join(" ");
const response = await octokit.search.issuesAndPullRequests({
q: `repo:${owner}/${repo} is:issue ${keywords}`,
per_page: 5,
});
作成を止めるわけではなく参考情報として表示するだけなので、エラー時は空配列を返してフローを阻害しないようにしています。
クローズ / 再オープン
Issue 作成後もスレッドを購読し続けているので、「クローズ」「再オープン」というメッセージだけで GitHub Issue の状態を変更できます。

if (isCloseRequest(userText)) {
await closeGitHubIssue(env.GITHUB_TOKEN, {
owner: state.repo!.owner,
repo: state.repo!.repo,
issueNumber: state.issueNumber!,
});
await thread.post(`:lock: Issue #${state.issueNumber} をクローズしました。`);
return;
}
Durable Objects で会話状態を永続化
Chat SDK は状態管理に StateAdapter インターフェースを使います。デフォルトのインメモリ実装はシンプルですが、Workers が再起動するとスレッドの購読状態が消えてしまいます。そこで Durable Objects を使った StateAdapter を自前で実装しました。
まず DurableObject を継承した ChatStateDO クラスを作ります。ストレージキーにプレフィックスを付けて Subscription / Lock / KV の名前空間を分けています。
export class ChatStateDO extends DurableObject<Env> {
async subscribe(threadId: string): Promise<void> {
await this.ctx.storage.put(`sub:${threadId}`, true);
}
async acquireLock(threadId: string, ttlMs: number): Promise<StoredLock | null> {
const existing = await this.ctx.storage.get<StoredLock>(`lock:${threadId}`);
if (existing && existing.expiresAt > Date.now()) return null;
const lock = { threadId, token: crypto.randomUUID(), expiresAt: Date.now() + ttlMs };
await this.ctx.storage.put(`lock:${threadId}`, lock);
return lock;
}
async getValue(key: string): Promise<string | null> {
const stored = await this.ctx.storage.get<StoredEntry>(`kv:${key}`);
if (!stored) return null;
if (stored.expiresAt !== null && stored.expiresAt <= Date.now()) {
await this.ctx.storage.delete(`kv:${key}`);
return null;
}
return stored.json;
}
// ...
}
Durable Objects の RPC では unknown 型がシリアライズ制約を満たさないため、KV の値は JSON 文字列にして送受信しています。
次に StateAdapter インターフェースを実装するアダプタークラスを作り、DO stub を呼び出すように橋渡しします。
class DurableObjectStateAdapter implements StateAdapter {
private stub: DurableObjectStub<ChatStateDO>;
constructor(namespace: DurableObjectNamespace<ChatStateDO>) {
const id = namespace.idFromName("chat-state");
this.stub = namespace.get(id);
}
async get<T = unknown>(key: string): Promise<T | null> {
const json = await this.stub.getValue(key);
return json != null ? (JSON.parse(json) as T) : null;
}
async set<T = unknown>(key: string, value: T, ttlMs?: number): Promise<void> {
await this.stub.setValue(key, JSON.stringify(value), ttlMs);
}
// ...
}
wrangler.toml に DO バインディングと migration を追加し、bot.ts でアダプターを渡すだけで Chat SDK が Durable Objects を使って状態を永続化します。
[[durable_objects.bindings]]
name = "CHAT_STATE"
class_name = "ChatStateDO"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["ChatStateDO"]
Durable Objects は 2025 年 4 月から Workers Free プランでも利用できるようになりました。ただし、Free プランで使えるのは SQLite ストレージバックエンドを使った DO のみです。そのため migration には new_classes ではなく new_sqlite_classes を指定します。
Slack スレッドのリンクを Issue に添付
Issue の本文末尾に、元の Slack スレッドへのリンクを自動で追加しています。GitHub Issue から Slack の会話に戻れるので、背景を確認したいときに便利です。
const slackLink = await getSlackPermalink(env.SLACK_BOT_TOKEN, thread.id);
const body = slackLink
? `${issueContent.body}\n\n---\n[元の Slack スレッド](${slackLink})`
: issueContent.body;
ファイル構成
src/
├── index.ts - Cloudflare Workers エントリーポイント
├── bot.ts - Chat SDK 初期化とイベントハンドラー
├── state.ts - Durable Objects StateAdapter 実装
├── auth.ts - Slack ユーザー認可チェック
├── github.ts - GitHub Issue 作成・更新・検索(@octokit/rest)
├── issue-builder.ts - AI による Issue 内容生成
├── slack-utils.ts - Slack API ユーティリティ(パーマリンク取得など)
└── durable-objects/
└── chat-state.ts - Durable Objects クラス(状態永続化)
デプロイ
Cloudflare Workers へのデプロイは Wrangler を使います。
# シークレットを設定
wrangler secret put SLACK_BOT_TOKEN
wrangler secret put SLACK_SIGNING_SECRET
wrangler secret put GITHUB_TOKEN
wrangler secret put ALLOWED_SLACK_USER_IDS
# デプロイ
bun run deploy
デプロイ後、Slack App の Event Subscriptions の Request URL に Workers の URL を設定して完了です。Slack App には以下のスコープが必要です。
| スコープ | 用途 |
|---|---|
app_mentions:read | メンション受信 |
channels:history | チャンネルメッセージ取得 |
groups:history | プライベートチャンネル対応 |
chat:write | メッセージ投稿 |
reactions:write | 処理中リアクション(⏳)追加 |
まとめ
Cloudflare Workers + Chat SDK + Workers AI の組み合わせで、Slack から GitHub Issue を作る Bot をシンプルに実装できました。
メンションでの Issue 作成に加えて、ラベル自動付与・重複チェック・デフォルトリポジトリ設定・クローズ/再オープンといった機能を追加することで、日常の Slack 作業から Issue 管理までシームレスにつなげられるようになりました。
Workers AI を使えば外部サービスの API キーが不要で、Cloudflare のダッシュボードだけで管理が完結します。Slack Bot を作りたい場合の選択肢として、検討してみてください。