Gemcook Tech Blog

AIを使ってBacklogの期限をSlackでリマインドする仕組みを作ってみた

に公開

はじめに

私の所属するプロジェクトでは Backlog を使って課題管理をしています。

直近で課題の期限管理を見直すターンがあり、私自身期限管理に対してのモチベーションが非常に高まっている時期がありました。そんな時ふと社内の連絡で使っているSlackに目が止まりました。

「課題の期限管理をSlackで出来たら便利なのでは?」

そんな気持ちから、自分で Backlog × Slack のリマインドシステムを作ることにしました。
この記事では、AI(ChatGPT・Claude code)にサポートしてもらいながら開発した体験をまとめています。

https://github.com/YoshitakaAzuma/BacklogReminder

まずは実現性をGPT-5に聞いてみる

今回作成するシステムへの要望は以下の通りです。

  • 1日の始まりと終わりに、自分が担当している課題の期限状況をSlackで手軽に確認したい

ただ、いきなり作り始めても進め方がわからなかったので、最近公開されたばかりの GPT-5 に壁打ちしてみました。

どうやら Backlog API と Slack Webhook を組み合わせれば実現できそうです。アプローチ方法もいくつか提案があり、その中でも今回はGitHubActions + Node.jsを利用する方針で進めようと思います。

機能要件を考える

実現可能なことがわかったところで、実際にどのようなシステムにするのかを整理します。

概要
GitHubActions + Node.js + BacklogAPI + Slack Webhook を利用して、既定の日時に課題の期限状況をSlackで通知する仕組み。

通知のタイミング
通知は平日の 9:30 と18:30 に定期実行。(祝日は除外)
「朝イチでタスクを確認」「夕方にやり残しをチェック」という運用を想定。

通知対象の課題
通知する課題の条件は以下の通りとする。

  • 担当が自分であること
  • ステータスが「完了」以外であること
  • 期限が「前日、当日、期限切れ」であること

通知メッセージの内容
通知内容は以下の通り整形する。

  • 期限ごとに分けてリスト化
  • 課題ごとに以下を表示
    • 課題キー
    • 課題名
    • 課題へのリンク
    • 現在のステータス

通知先
課題通知専用のSlackで専用のチャンネル。

実装手順を考えてもらう

大まかに機能の要件をまとめることができたので、次は実装の手順をAIに考えてもらいました。GPT-5になってから思考時間が長くなったように感じます。(今回は 1m39s かかりました)

GPTが考えてくれた手順項目は以下の6項目です。

  1. Slackの準備(Incoming Webhook)
  2. Backlogの準備(APIキー)
  3. GitHubリポジトリを用意
  4. GitHub Actions のワークフロー
  5. GitHub Secrets の設定
  6. 動作確認

各項目ごとに細かい手順も書いてくれているのでそれを頼りに進めていきます。

AIが作成した手順をもとに実装を進める

それでは早速手順に沿って実装を進めていきます。

Slackの準備

専用チャンネルの作成
今回作成するシステムは個人利用目的なので、会社のワークスペースではなく個人のワークスペースに専用のチャンネル(backlog-reminder)を作成しました。

Webhook URLの発行
続いてWebhook URLを発行します。このURLを利用することで特定のチャンネルに通知を送ることができます。発行方法は以下の記事を参考にさせていただきました。

https://zenn.dev/hotaka_noda/articles/4a6f0ccee73a18

今回はWebhookを利用しましたが、Bot APIを利用するとユーザごとにDMを送ることも可能なようです。

Backlogの準備

APIキーの発行
BacklogのAPIキーを発行します。発行方法は公式で紹介されていて、非常に簡単です。

https://support-ja.backlog.com/hc/ja/articles/360035641754-APIの設定

スペースドメインの確認
課題の取得の際に必要なスペースドメインの確認をしておきます。スペースドメインはBacklogのURLに含まれているので、そこから確認することができます。

  • 例:https://{スペースドメイン}.backlog.jp/

GitHubリポジトリを用意

空のリポジトリを作成して、package.jsonsrc/index.tstsconfig.jsonを配置します。コードはGPTが作成してくれました。

package.json
{
  "name": "backlog-slack-reminder",
  "version": "1.0.0",
  "scripts": {
    "start": "ts-node src/index.ts",
    "build": "tsc",
    "lint": "tsc --noEmit",
    "test": "echo \"No tests specified\" && exit 0"
  },
  "dependencies": {
    "date-holidays": "^3.0.2",
    "luxon": "^3.4.4"
  },
  "devDependencies": {
    "@types/luxon": "^3.7.1",
    "@types/node": "^24.3.0",
    "ts-node": "^10.9.2",
    "typescript": "^5.9.2"
  }
}
src/index.ts
import { DateTime } from 'luxon';
import Holidays from 'date-holidays';

// ==== 型定義 ====
interface BacklogStatus {
  id: number;
  projectId: number;
  name: string;
  color: string;
  displayOrder: number;
}

interface BacklogIssue {
  id: number;
  projectId: number;
  issueKey: string;
  keyId: number;
  summary: string;
  description: string;
  dueDate: string | null;
  assignee: {
    id: number;
    userId: string;
    name: string;
    roleType: number;
    lang: string | null;
    mailAddress: string;
  } | null;
  status: {
    id: number;
    projectId: number;
    name: string;
    color: string;
    displayOrder: number;
  };
  priority: {
    id: number;
    name: string;
  };
  issueType: {
    id: number;
    projectId: number;
    name: string;
    color: string;
    displayOrder: number;
  };
  created: string;
  updated: string;
}

interface BacklogUser {
  id: number;
  userId: string;
  name: string;
  roleType: number;
  lang: string | null;
  mailAddress: string;
}

interface IssueGroups {
  overdue: BacklogIssue[];
  today: BacklogIssue[];
  tomorrow: BacklogIssue[];
}

interface SlackMessage {
  text: string;
}

// ==== 環境変数 ====
const SPACE: string | undefined = process.env.BACKLOG_SPACE;          // 例: "your-space"
const DOMAIN: string = process.env.BACKLOG_DOMAIN || 'backlog.jp'; // "backlog.jp" or "backlog.com"
const API_KEY: string | undefined = process.env.BACKLOG_API_KEY;      // Backlog API key
const SLACK_WEBHOOK_URL: string | undefined = process.env.SLACK_WEBHOOK_URL;
const TIMEZONE: string = process.env.TIMEZONE || 'Asia/Tokyo';
const SKIP_HOLIDAYS: boolean = (process.env.SKIP_HOLIDAYS || 'true') === 'true';

// ==== 日付ユーティリティ(JST基準)====
const today = DateTime.now().setZone(TIMEZONE).startOf('day');
const tomorrow = today.plus({ days: 1 });
const iso = (d: DateTime): string => d.toISODate() || ''; // YYYY-MM-DD

// ==== 祝日スキップ ====
if (SKIP_HOLIDAYS) {
  const hd = new Holidays('JP');
  const hol = hd.isHoliday(today.toJSDate());
  if (hol && Array.isArray(hol) && hol.length > 0) {
    console.log(`祝日(${hol[0].name})のため通知をスキップします: ${iso(today)}`);
    process.exit(0);
  }
}

// ==== Backlog API 基本関数 ====
const API_BASE = `https://${SPACE}.${DOMAIN}/api/v2`;

const fetchJson = async (url: string): Promise<any> => {
  const res = await fetch(url);
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`HTTP ${res.status}: ${text}`);
  }
  return res.json();
};

const getMyself = async (): Promise<BacklogUser> => {
  const url = `${API_BASE}/users/myself?apiKey=${API_KEY}`;
  return fetchJson(url);
};

const getProjectStatuses = async (projectId: number): Promise<BacklogStatus[]> => {
  const url = `${API_BASE}/projects/${projectId}/statuses?apiKey=${API_KEY}`;
  return fetchJson(url);
};

const getCompletedStatusIds = async (projectIds: number[]): Promise<number[]> => {
  const completedIds: number[] = [];
  
  for (const projectId of projectIds) {
    const statuses = await getProjectStatuses(projectId);
    // 「完了」のみを対象とする
    const completedStatuses = statuses.filter(status => 
      /完了|completed/i.test(status.name)
    );
    completedIds.push(...completedStatuses.map(s => s.id));
  }
  
  return completedIds;
};

const fetchAllIssues = async (params: Record<string, string>): Promise<BacklogIssue[]> => {
  // params: object -> querystring
  const q = new URLSearchParams(params);
  // ページング
  const count = 100;
  let offset = 0;
  let all: BacklogIssue[] = [];
  while (true) {
    q.set('count', String(count));
    q.set('offset', String(offset));
    const url = `${API_BASE}/issues?${q.toString()}`;
    const page: BacklogIssue[] = await fetchJson(url);
    all = all.concat(page);
    if (page.length < count) break;
    offset += count;
  }
  return all;
};

// ==== メインロジック ====
// 要件: 全ての課題 / 期限が「残り3日」「残り2日」「当日」「期限切れ」
(async () => {
  if (!SPACE || !API_KEY || !SLACK_WEBHOOK_URL) {
    throw new Error('環境変数 BACKLOG_SPACE / BACKLOG_API_KEY / SLACK_WEBHOOK_URL が未設定です。');
  }

  // 期限の範囲:過去(期限切れ含む)〜明日までを一気に取得してグルーピング
  const since = today.minus({ days: 365 }); // 1年分拾えば十分。必要に応じて短縮可
  const until = tomorrow;

  // 自分に担当された課題のみ取得
  const myself = await getMyself();
  const allIssues = await fetchAllIssues({
    apiKey: API_KEY,
    'assigneeId[]': String(myself.id),
    dueDateSince: iso(since),
    dueDateUntil: iso(until),
    sort: 'dueDate',
    order: 'asc'
  });

  // プロジェクトIDを抽出
  const projectIds = [...new Set(allIssues.map(issue => issue.projectId))];
  
  // 完了ステータスのIDを取得
  const completedStatusIds = await getCompletedStatusIds(projectIds);
  
  // 完了ステータス以外の課題のみにフィルタリング
  const issues = allIssues.filter(issue => 
    !completedStatusIds.includes(issue.status.id)
  );

  // グルーピング
  const groups: IssueGroups = {
    overdue: [], // 期限切れ(todayより過去)
    today: [],   // 当日
    tomorrow: [] // 明日
  };

  for (const i of issues) {
    if (!i.dueDate) continue; // 期限なしは対象外
    const due = DateTime.fromISO(i.dueDate, { zone: TIMEZONE });
    const diffDays = Math.floor(due.diff(today, 'days').days); // due - today(日単位)

    if (diffDays < 0) groups.overdue.push(i);
    else if (diffDays === 0) groups.today.push(i);
    else if (diffDays === 1) groups.tomorrow.push(i);
  }

    // Slack メッセージ整形
  const issueLine = (it: BacklogIssue): string =>
    `• <https://${SPACE}.${DOMAIN}/view/${it.issueKey}|${it.issueKey}> ${it.summary} [${it.status.name}]`;

  const section = (title: string, arr: BacklogIssue[]): string =>
    arr.length
      ? `*${title}*\n${arr.map(issueLine).join('\n')}`
      : `*${title}*\n(該当なし)`;

  const text: string = [
    `:spiral_calendar_pad: Backlog 期限リマインド (${iso(today)})`,
    section('🟥 期限切れ', groups.overdue),
    section('🟧 当日', groups.today),
    section('🟨 明日', groups.tomorrow)
  ].join('\n\n');

  // 何もなければ送らない運用にしたい場合は以下でreturn
  // const total = groups.overdue.length + groups.today.length + groups.tomorrow.length;
  // if (total === 0) { console.log('該当なしのため送信しません'); return; }

  // Slack送信
  const slackPayload: SlackMessage = { text };
  const res = await fetch(SLACK_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(slackPayload)
  });
  if (!res.ok) {
    const t = await res.text();
    throw new Error(`Slack送信失敗: HTTP ${res.status} ${t}`);
  }

  console.log('Slackへ送信しました。');
})().catch((e: Error) => {
  console.error(e);
  process.exit(1);
});
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": [
    "src/**/*"
  ],
  "exclude": [
    "node_modules",
    "dist"
  ]
}

GitHub Actionsのワークフロー

ymlファイルの設置
GitHub Actionsで実行するためのymlファイルを設置していきます。実行時間は9:30 と 18:30 です。

.github/workflows/reminder.yml
name: Backlog Daily Reminders

on:
  schedule:
    - cron: "30 0 * * 1-5" # JST 09:30 (Mon-Fri)
    - cron: "30 9 * * 1-5" # JST 18:30 (Mon-Fri)
  workflow_dispatch: {}     # 手動実行も可

jobs:
  send:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install deps
        run: npm ci

      - name: Run reminder
        run: npm start
        env:
          BACKLOG_SPACE: ${{ secrets.BACKLOG_SPACE }}
          BACKLOG_DOMAIN: ${{ secrets.BACKLOG_DOMAIN }} # 例: backlog.jp / backlog.com
          BACKLOG_API_KEY: ${{ secrets.BACKLOG_API_KEY }}
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
          TIMEZONE: Asia/Tokyo
          SKIP_HOLIDAYS: "true"

GitHub Secrets の設定

環境変数の設定
APIキーなどの環境変数をGitHub Secretsに設定していきます。リポジトリの SettingsSecrets and variablesActionsNew repository secret から設定することができます。今回設定した環境変数は以下の通りです。

  • BACKLOG_SPACE(例:gemcook)
  • BACKLOG_DOMAIN(backlog.jp か backlog.com)
  • BACKLOG_API_KEY
  • SLACK_WEBHOOK_URL

動作確認

workflowの実施
一通り実装し終えたので、手動でworkflowを実行して動作確認をしてみました。

実行結果を確認すると、依存関係のインストールの部分でエラーが発生した模様。原因がすぐに思い浮かばなかったので雑にGPTにエラー文を投げてみたところ、すぐに答えがわかりました。

どうやらpackage-lock.jsonが存在していないことが原因だったようです。一度ローカルでnpm installをして、生成されたpackage-lock.jsonをリポジトリにpushすると問題なくワークフローを実行できました!

Slackにもメッセージが届いていました。

課題の表示確認
現在私が持っている課題で期限が近いものがなかったので、自分以外の課題も通知されるように変更して動作確認をします。今回はclaude codeにコードの修正をしてもらいました。

修正後、手動でworkflowを実行してSlackのメッセージを確認してみます。
正常に課題を取得できており、URLやステータス表示も問題ないことを確認できました!(課題の情報は伏せさせていただきます)

定期実行の確認
最後にGitHubActionsのcronによる定期実行の動作確認を行います。

claude codeに日曜日の 14:00 に実行されるスケジュールを.github/workflows /reminder.ymlに追加してもらいました。

on:
  schedule:
    - cron: "30 0 * * 1-5" # JST 09:30 (Mon-Fri)
+   - cron: "0 5 * * 0-6" # JST 14:00 (San-Sat)
    - cron: "30 9 * * 1-5" # JST 18:30 (Mon-Fri)

14:00 を過ぎてメッセージが届いたのですが、、、めちゃめちゃ遅れて届きました。。。

遅延時間はなんと24分もありました。GitHub Actions の cron に関する記事いくつか読んだのですが、20分前後の遅延は当たり前に発生するようです。

遅延が起こらないようにする

20分程度の遅延であればそれを見越した時間に設定しておくことである程度ストレスなく利用できそうですが、今回は以下の記事を参考にして遅延が起こらないようにしてみます。

https://zenn.dev/no4_dev/articles/14b295b8dafbfd

具体的には、GitHubのWebhookであるrepository_dispatchと、任意のタイミングでhttpリクエストを送ることができるcron-jobという無料サービスを組み合わせて実現します。

https://cron-job.org/en/

https://docs.github.com/ja/actions/reference/workflows-and-actions/events-that-trigger-workflows#repository_dispatch

repository_dispatchの設定

まずはyamlファイルを外部からrepository_dispatchで起動できるようにします。GitHubの公式ページでrepository_dispatchの説明をしている項目のURLと、現在のyamlファイルをGPTに渡して修正してもらいました。

on:
- schedule:
+ repository_dispatch:
-   - cron: "30 0 * * 1-5" # JST 09:30 (Mon-Fri)
-   - cron: "0 5 * * 0-6" # JST 13:40 (Sun-Sat)
-   - cron: "30 9 * * 1-5" # JST 18:30 (Mon-Fri)
+   types: [backlog-reminder]   # 任意のイベント名
  workflow_dispatch: {}     # 手動実行も可

GitHub Personal Access Token の発行

cron-jobの設定に必要である GitHub Personal Access Token を発行します。今回BacklogReminderのリポジトリ指定かつ有効期限1年で作成しました。tokenには以下の権限を設定しました。

  • Contentsの読み取り書き込み

cron-jobの設定

次にcron-jobの設定を進めていきます。
参考記事を元に、URL・Execution schedule・Headers・Request body
を入力し、接続テストを実行しました。

無事リクエストが送信されました!

遅延の確認

最後にcron-jobの遅延の有無を確かめるために実行スケジュールを数分後の時刻で設定してみました。(Time zoneをAsia/Tokyoにしている場合日本時間で設定できます)

実行結果はなんと、誤差4秒で収まっていました!GitHub Actionsに比べて非常に正確です。


10:35に設定していた

最終調整

一つのCronjobでは一日2回の実行ができなかったので、9:30 と 18:30 の二つのjobを設定することで1日2回別の時間での通知を実現しました。

まとめ

実際に使ってみた感想

翌日から早速実務で利用し始めたのですが、1日の始まりと終わりに自分が担当している課題の期限状況をSlackで手軽に確認できることで、課題の管理が非常に楽になりました。

今回の仕組みはあくまで自分用のリマインドシステムとして作りましたが、拡張すればチーム全体にも活用できそうだと感じました。

終わりに

思いつきで作り始めたシステムでしたが、「実際に使えるもの」をごく短期間で作れたのは、AIのサポートがあったからこそだと思います。今回のように、「欲しいものをすぐ形にして試せる」のはAI時代の個人開発の大きな強みですね。

Backlogを利用している方はぜひ一度試してみてください!

Gemcook Tech Blog
Gemcook Tech Blog

Discussion