GitHub Actionsの夜間バッチの失敗と改善フロー図。開発者が、createdAtで処理していた古いロジックがAPI制限で失敗し、updated_atに切り替えてDiscord通知で失敗を検知する修正内容を図解しています。

はじめに

Issue を夜間に自動分類する CI ジョブが、3 週間ほど毎晩失敗していました。
原因は 2 つあります。
1 つは、bot が自分のコメントを編集するたびに、判定済みの Issue が毎晩「未処理の候補」に戻っていたことです。
もう 1 つは、失敗しても通知が無く、API の残高不足(billing_error)に 21 晩気づけなかったことです。

この記事では、候補の絞り込みを createdAt から updated_at ベースに直した方法と、失敗したときだけ Discord に知らせる job の作り方をまとめます。
コードはそのままコピーして試せる形にしています。

こんな人におすすめ

  • schedule で動く GitHub Actions の workflow を運用していて、失敗に気づけるか不安な方
  • bot に Issue やコメントを書かせていて、自分の書き込みが次回のトリガーになってしまった経験がある方
  • LLM を使った夜間バッチで、1 回の処理量や積み残しの扱いに悩んでいる方
  • 長い jq のフィルタが読みにくくなってきた方

罠1: createdAt では「最後に触った時刻」が分からない

bot は分類結果をコメントに書き、再実行時は gh issue comment --edit-last で上書きしていました。
このとき、コメントの createdAt は初回のままです。
一方で、Issue の updatedAt は編集のたびに進みます。

「最新の bot コメントより Issue が新しければ、再判定する」という条件を createdAt で比べていたため、次のことが起きていました。

  1. bot がコメントを編集する
  2. Issue の updatedAt が進む
  3. updatedAt が bot コメントの createdAt より新しくなる
  4. 翌晩、また候補に入る

つまり、自分の書き込みが自分のトリガーになっていました。

gh issue list --json comments が返すのは createdAt だけで、編集時刻は取れません。
編集時刻の updated_at が必要なら、REST API を直接叩きます。

gh api "repos/OWNER/REPO/issues/comments?since=2026-10-01T00:00:00Z" \
  --paginate \
  --jq '.[] | {issue_url, user: .user.login, updated_at}'

since を指定すると、その日時以降に更新されたコメントだけを取得できます。

許容幅つきの判定関数に切り替える

ラベル付与など、コメント編集の前後で Issue の更新が少しずれることがあります。
そのため、完全一致ではなく「コメントの updated_at + 5 分」の許容幅を持たせました。

export function isIssueUpdatedWithinBotCommentSlack(
  issueUpdatedAt,
  botCommentUpdatedAt,
  slackMs = 5 * 60 * 1000,
) {
  const issueMs = Date.parse(issueUpdatedAt)
  const commentMs = Date.parse(botCommentUpdatedAt)
  if (!Number.isFinite(issueMs) || !Number.isFinite(commentMs)) return false
  return issueMs <= commentMs + slackMs
}

この関数が true を返す Issue は、bot 自身の操作で進んだだけとみなして候補から外します。
bot コメントが無い場合は Date.parse が NaN になって false を返すので、初見の Issue は必ず候補に残ります。

人間が本文やラベルを変えた場合は、許容幅を超えて updatedAt が進みます。
その Issue は引き続き再判定の対象になります。

jq のフィルタは Node スクリプトとテストに移す

候補の列挙は、もともと jq の長い heredoc で書いていました。
ISO 時刻の比較、コメントと Issue の突き合わせ、ソートまで入ると、jq では読みにくく、テストもできません。

Node スクリプトに移すと、単体テストが書けます。
今回は、誤って拾っていた過去の Issue をそのままテストケースにできました。

import { test } from 'node:test'
import assert from 'node:assert/strict'
import { isIssueUpdatedWithinBotCommentSlack as within } from './triage-candidates.mjs'

test('bot の編集直後の更新は除外する', () => {
  assert.equal(within('2026-10-01T00:03:00Z', '2026-10-01T00:00:00Z'), true)
})

test('許容幅を超えた更新は人間の編集として残す', () => {
  assert.equal(within('2026-10-01T01:00:00Z', '2026-10-01T00:00:00Z'), false)
})

test('bot コメントが無ければ候補に残す', () => {
  assert.equal(within('2026-10-01T00:00:00Z', undefined), false)
})

1 回の処理量に上限を付ける

LLM を使うジョブでは、候補が多いと error_max_turns で止まることがあります。
更新の古い順に最大 20 件までとし、全件数と残り件数を Job Summary に出すようにしました。

eligible.sort((l, r) => Date.parse(l.updatedAt) - Date.parse(r.updatedAt) || l.number - r.number)
const selected = eligible.slice(0, maxCandidates)
return {
  candidates: selected.map(toCandidate),
  totalCount: eligible.length,
  remainingCount: Math.max(0, eligible.length - selected.length),
}

古いものから処理するので、積み残しが永遠に後回しになることはありません。
残り件数が見えると、「今夜で終わるのか、数晩かかるのか」も分かります。

失敗したときだけ Discord に通知する

通知用の job を別に切り出し、triage job が成功以外で終わったときだけ動かします。

notify:
  if: always() && needs.triage.result != 'success'
  needs: triage
  uses: ./.github/workflows/_notify-discord.yml
  with:
    workflow_name: auto-dev-triage-nightly
    status: ${{ needs.triage.result }}
    summary_metric: ${{ needs.triage.outputs.summary_metric }}
    notify_on_success: false

if: always() を付けないと、needs の job が失敗した時点で通知 job もスキップされます。
別 job に分けると、通知用の再利用ワークフローを uses で呼べて、成功時は無音になります。

実行ログには Issue 本文に相当する内容が入ります。
そのまま通知に流さないよう、許可リスト(billing_error / error_max_turns)に一致した種類名だけを summary_metric として渡しています。

summary_metric: claude_error=billing_error

つまづきやすいポイント

  • if: always() を忘れると通知が来ません。 通知 job は needs 先が失敗するとスキップされます。cancelled も拾いたいので、!= 'success' で判定します。
  • 許容幅を広げすぎると、人間の編集を見逃します。 5 分はあくまで目安です。実測した遅延を見て調整してください。
  • 残高不足は再実行では直りません。 コードの修正ではなく、オーナーによる補充が必要です。気づく手段(通知)と対処する人を runbook に書いておくと、次に迷いません。
  • 通知にログをそのまま載せないでください。 非公開の本文が外部サービスに流れます。種類名だけに絞るのが安全です。

まとめ

createdAt は「最後に触った時刻」ではないため、編集される前提のコメントには updated_at を使います。
自分の書き込みが自分のトリガーになる構造は、許容幅つきの比較で断ち切れます。
あわせて、schedule で動く workflow には、失敗時だけ鳴る通知を最初から付けておくと、21 晩も気づかない事態を避けられます。
他の通知なしの定期実行にも、同じ仕組みを広げていく予定です。