
はじめに
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 で比べていたため、次のことが起きていました。
- bot がコメントを編集する
- Issue の
updatedAtが進む updatedAtが bot コメントのcreatedAtより新しくなる- 翌晩、また候補に入る
つまり、自分の書き込みが自分のトリガーになっていました。
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 晩も気づかない事態を避けられます。
他の通知なしの定期実行にも、同じ仕組みを広げていく予定です。

