关于 update-issue-tree
用于盘点现有 GitHub Issue 树并更新根 Issue 管理区块的技能,面向维护分阶段任务计划的团队。它可整理孤立 Issue、修复父子关系与 Phase 标签,并保留手写正文。
主要功能
- 01递归获取 Issue 树及状态
- 02重新归置孤立或残留 Issue
- 03同步 Phase 标签
- 04更新管理区块并保留手写内容
- 05按设定粒度识别待拆分任务
适用场景
- 01整理已关闭父 Issue 下的开放任务
- 02将孤立 Issue 归入对应 Phase
- 03同步树内 Issue 的 Phase 标签
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ fandhe-ai/ agent-cli-skills --skill update-issue-tree安装后在 Claude Code 中输入/update-issue-tree确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
scripts/reassign-lib.shreassign-sub-issue.shupdate-root-body.shtests/reassign-sub-issue.test.mjsskill-invocation-block.test.mjsskill-prerequisites.test.mjsstep8-root-body.test.mjstests/lib/gh-stub.mjstmp-track.mjs详细介绍
update-issue-tree
既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。
使い方
ルートのトラッキング issue 番号を引数として渡す。
--granularity オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。
2h・4h・1h のように正整数+時間単位(h)で指定する。優先順位は
--granularity 明示 > ルート issue 本文の `` マーカー > 既定 2h
の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で GRANULARITY
として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、
GRANULARITY の確定値でマーカー行を先頭に 1 行だけ保持する。
update-issue-tree 42
update-issue-tree 42 --granularity 4h
前提条件
ghCLI がインストールされ、認証済みであること(gh auth statusで確認)- 対象リポジトリへの Issue 書き込み権限があること
- 対象ツリーは単一リポジトリ内で完結していること。GitHub の sub-issues はリポジトリを
跨いで紐付けられるが、本スキルは cross-repository sub-issue を対象外とする。
親リポジトリへの書き込みは本スキルの前提条件(対象リポジトリへの書き込み権限)の
外側にあり、誤ったリポジトリの同番号 issue を操作する事故を構造的に防ぐため。この契約の実装は
scripts/reassign-sub-issue.shの exit 2 による fail-closed(下記 Step 3 の終了コード表を参照)。exit 2 は gh/jq 不在・未認証・issue 取得失敗(解消可能な前提不備)とも共有するため、このケースだけ stderr にreason=cross-repository-parentの安定マーカー行が追加で出る(終了コード表参照) - 対象 issue の親が別リポジトリにある場合、
scripts/reassign-sub-issue.shがその issue を 処理できるのは、親リポジトリ側で親リンクが取り外されparent_issue_urlが null(どの親にも 紐付いていない)状態になってからである。cross-repository の親リンクの取り外しは 親リポジトリ側の操作であり、そのリポジトリへの書き込み権限を持つ担当者が行う。本スキルの範囲外 - 同一リポジトリ内での親の付け替えは本スキルの中核機能であり、上記の null 要求の対象外である。
Step 3 は承認済みの旧親を
--old-parentに渡して DELETE→POST を実行するため、事前にparent_issue_urlを null にしておく必要はない(孤児の再配置は Step 4 が--old-parent省略で扱う)
フロー
Step 1: ツリー全体を再帰取得する
ルート issue から sub_issues API を再帰的に呼び出し、全階層のツリー構造を取得する。
ページネーションを考慮し、per_page=100 で全件取得する。
ROOT_NUMBER="<ルート issue 番号>"
# --granularity の値は代入前に Claude 側でも同じ正規表現(^[1-9][0-9]*h$)で検証し、
# 不一致なら**このフェンスを実行せず**ユーザーに再指定を求める(未信頼値を生成済みシェルへ
# 埋め込まない)。優先順位は --granularity > ルートのマーカー > 既定 2h の順。
# --granularity で渡された時間を実際の値で代入する(実行時に Claude が置き換える)
# 例: --granularity 4h が指定された場合 → GRANULARITY_ARG="4h" / 未指定の場合は空文字
GRANULARITY_ARG="<--granularity で渡された時間(未指定なら空文字)>"
if [[ -n "${GRANULARITY_ARG}" ]]; then
GRANULARITY="${GRANULARITY_ARG}"
GRANULARITY_SOURCE="--granularity 引数"
else
# ルート本文の取得失敗とマーカー不在は分離する。取得失敗は「粒度を既定へ倒さず中止」する
# (gh issue view 自体の失敗を || true で握り潰すと、権限エラー・issue 不在等を
# 「マーカーなし」と誤読して既定 2h へサイレントに倒れてしまうため)
ROOT_BODY=$(gh issue view "${ROOT_NUMBER}" --json body --jq '.body') \
|| { echo "エラー: ルート issue #${ROOT_NUMBER} の本文を取得できません。粒度を既定へ倒さず中止します。"; exit 1; }
# 既存ルート本文の マーカーから継承する。マーカーが無ければ既定 2h
# (|| true は grep のマーカー不在にのみ掛かる。gh issue view 自体の失敗は上で既に処理済み)
ROOT_GRANULARITY=$(printf '%s\n' "${ROOT_BODY}" \
| grep -oE '' | head -1 | grep -oE '[1-9][0-9]*h' || true)
if [[ -n "${ROOT_GRANULARITY}" ]]; then
GRANULARITY="${ROOT_GRANULARITY}"
GRANULARITY_SOURCE="ルート issue #${ROOT_NUMBER} のマーカー"
else
GRANULARITY="2h"
GRANULARITY_SOURCE="既定値"
fi
fi
# 許可する構文は正整数 + h のみ(例: 1h / 2h / 4h)。引用符・空白・コマンド置換・0h・単位なしは
# ここで拒否する(ROOT_GRANULARITY はマーカー抽出時点で同じ正規表現を通しているが、
# GRANULARITY_ARG はユーザー入力の生値のため、代入元によらずこの検証を必ず通す)
if ! printf '%s' "${GRANULARITY}" | grep -qE '^[1-9][0-9]*h$'; then
echo "エラー: --granularity の値 '${GRANULARITY}' は不正です(許可: 正整数+h、例 2h)。中止します。"
exit 1
fi
# 確定値と由来を必ず出力する(既定 2h と読み替えないことをここで可視化する)
echo "粒度基準: ${GRANULARITY}(由来: ${GRANULARITY_SOURCE})"
# ルート直下の sub-issues を取得(ページネーション対応)
fetch_sub_issues() {
local PARENT="${1}"
local PAGE=1
while true; do
RESULT=$(gh api \
"repos/{owner}/{repo}/issues/${PARENT}/sub_issues?per_page=100&page=${PAGE}")
echo "${RESULT}"
COUNT=$(echo "${RESULT}" | jq 'length')
if [ "${COUNT}" -lt 100 ]; then break; fi
PAGE=$((PAGE + 1))
done
}
# ルートから再帰的にツリーを構築
fetch_sub_issues "${ROOT_NUMBER}"
以降の Step は Step 1 で出力された GRANULARITY の値を使う(既定 2h と読み替えない)。
各 issue の state(open / closed)・ラベル・タイトルを記録してツリーマップを作成する。
Step 2: 棚卸し対象を特定する
取得したツリーマップを分析し、以下のケースを特定する。
| ケース | 対応方針 |
|---|---|
| closed 親の下に open issue が残置されている | 適切な open Phase 親へ付け替え |
| どの親にも紐付いていない孤児 issue がある | 該当 Phase 親へ紐付け(Phase が不明な場合はユーザーに確認) |
| phase ラベルが親と一致しない issue がある | ラベルを同期 |
| 既存 Phase に収まらない新規タスクがある | 新 Phase 親の新設を検討 |
実装時間が ${GRANULARITY}(Step 1 で確定。既定 2h)超の issue が分解されていない | sub-issue に分解(create-issue-tree と同じ粒度基準) |
| 対象 issue の親が別リポジトリにある(cross-repository sub-issue) | 本スキルの対象外。棚卸し対象から除外し、Step 9 の要確認事項へ記載する |
棚卸し対象の一覧をユーザーに提示し、方針確認を取ってから変更を実行する。
承認された対象を Step 3 / Step 4 が読む 2 つの計画配列へ落とす。 配列は必ず宣言する
(対象 0 件でも空配列として宣言する)。未宣言のまま Step 3 / Step 4 へ進むと
"${REASSIGN_PLAN[@]}" が空展開され、承認済みの対象があってもエラーなく 0 件で完走して
完了報告まで進んでしまうため、両ステップの冒頭で宣言の有無を検査して fail-closed で停止する。
# 承認された「closed 親下の付け替え」対象。要素は "<issue> <old-parent> <new-parent>"。
# 空白区切りの 1 行 1 件にするのは、Step 3 のループが read で分割して受け取るため。
# issue 番号は全て 10 進の正整数であり、空白・改行を含まないことを Step 1 の取得時に保証する。
REASSIGN_PLAN=(
# "123 456 789"
)
# 承認された「孤児の再配置」対象。要素は "<orphan-issue> <phase-parent>"。
ORPHAN_PLAN=(
# "234 789"
)
対象が 0 件の場合も上記のとおり空配列として宣言する。「対象なし(空配列)」と 「計画未設定(未宣言)」は意味が異なり、後者は Step 2 の実行漏れであって正常系ではない。
Step 3: closed 親下の残置 open issue を付け替える
closed 親の下に残置されている open issue を、対応する open Phase 親へ移動する。
「旧親から DELETE → 新親へ POST」の 2 段操作と、その前後の冪等性判定・事後確認は
scripts/reassign-sub-issue.sh に集約されている(SKILL.md 本文に素の gh api を並べると、
DELETE 失敗検知なしに POST へ進む等の欠陥を生むため)。
その呼び出し側で必要な共有処理(計画配列の存在・型検証、1 件呼び出しの終了ステータス解釈)は
scripts/reassign-lib.sh に切り出してあり、Step 3 / Step 4 がそれぞれ source して使う。
各コードフェンスは独立したシェルで実行され得る(reassign-sub-issue.sh の設計前提と同じ)。
そのため共有処理をどちらかのフェンス内で定義して他方が再利用する構成にはしない。Step 4 の
実行時に関数定義が継承されず command not found となり、孤児の再配置が一切実行されないため。両 Step のフェンスは同一の前置きで
reassign-lib.sh を自己完結的に解決・source する。
このスキルの配置ルートは導入形態(本リポジトリのソース/npx skills add による
vendoring/.claude/skills/ symlink 経由)で異なる。source 前に 3 レイアウトを順に確認し、
実在するものを採用する(implement-issue-tree の scriptPath 3 レイアウト・contribute-skill の
LOCAL_SKILL_DIR 解決と同じ考え方)。reassign-sub-issue.sh のパスはここでは指定しない——
ライブラリが自身の実体位置から兄弟として解決するため、どの導入形態でも対の実体が必ず組み合う。
# reassign-lib.sh を 3 レイアウトから解決して source する。
# **この前置きは Step 3 / Step 4 の両フェンスが同一の内容で持つ。** 手順書のコードフェンスは
# ブロックごとに独立シェルで実行され得るため、片方のフェンスで定義した関数・変数がもう一方へ
# 継承される保証がない(Issue #372 / PR #374 codex-review P1)。
REASSIGN_LIB=""
for CANDIDATE in \
"skills/update-issue-tree/scripts/reassign-lib.sh" \
".agents/skills/update-issue-tree/scripts/reassign-lib.sh" \
".claude/skills/update-issue-tree/scripts/reassign-lib.sh"; do
# 存在確認は -f のみで行う(-x にすると、npx skills add 等の vendoring で
# 実行ビットが落ちたファイルを「存在しない」と誤検知し、3 レイアウトいずれにも
# 見つからないという誤ったエラーメッセージになる)
if [[ -f "${CANDIDATE}" ]]; then
REASSIGN_LIB="${CANDIDATE}"
break
fi
done
if [[ -z "${REASSIGN_LIB}" ]]; then
echo "エラー: reassign-lib.sh が見つからない(3 レイアウトいずれにも存在しない)" >&2
exit 1
fi
# ライブラリは対の reassign-sub-issue.sh の存在まで確認したうえで、reassign_one /
# require_plan_array と REASSIGN_SCRIPT を定義する。欠落していれば非ゼロを返すので中断する。
# このフェンスで set -euo pipefail を使ってはならない(理由は reassign-lib.sh 冒頭のコメント参照。
# set -e があると reassign_one 内の `status=$?` 退避に到達せず、Issue #335 の欠陥が再発する)
source "${REASSIGN_LIB}" || exit 1
# 計画配列の存在・型ガード(fail-closed)。詳細は reassign-lib.sh の require_plan_array を参照
require_plan_array REASSIGN_PLAN || exit 1
# 呼び出し側ループ。契約の主体はここにある——「(b) 恒久的な対象外は次の 1 件へ進み、
# (a) 解消可能な前提不備は原因解消まで中断する」を実際に実現するのはこのループである。
# ISSUE_NUMBER / OLD_PARENT / NEW_PARENT は Step 2 で構築した REASSIGN_PLAN から供給する。
SKIPPED=()
NEEDS_REVIEW=()
for ENTRY in "${REASSIGN_PLAN[@]}"; do
# ENTRY は "<issue> <old-parent> <new-parent>" 形式。
# zsh は未クォートの展開を単語分割しないため、read で明示的に分割する
IFS=' ' read -r ISSUE_NUMBER OLD_PARENT NEW_PARENT <<< "${ENTRY}"
# `if reassign_one ...; then ... fi` の形にしてはならない。条件が偽で else が無い場合、
# `fi` の直後の $? は 0 になり(実測済み)、失敗が「成功」として読まれて
# (a)/(b)/(c) の判定も中断もすべて素通りする。`|| status=$?` で明示的に退避する。
status=0
reassign_one --issue "${ISSUE_NUMBER}" \
--old-parent "${OLD_PARENT}" \
--new-parent "${NEW_PARENT}" || status=$?
if (( status == 0 )); then
continue
fi
if (( status == 9 )); then
# (b) cross-repository 親。棚卸し対象から除外して次へ(Step 9 の要確認事項へ記載)
SKIPPED+=("${ISSUE_NUMBER}")
continue
fi
if (( status == 10 )); then
# (c) 補償復旧が成功し旧親へ復帰済み(本来の付け替えは未達)。fatal にせず要確認事項へ
# 記録して次の 1 件へ進む。exit 10 を中断扱いにすると残りの REASSIGN_PLAN が未処理のまま
# 中断し、Step 4 の孤児処理・Step 9 の報告経路自体がスキップされてしまう
NEEDS_REVIEW+=("#${ISSUE_NUMBER}: exit=10 restored — 旧親 #${OLD_PARENT} へ復帰済み。新親 #${NEW_PARENT} への付け替えは未達")
continue
fi
if (( status == 11 )); then
# (c) DELETE 後、第三者が別親を設定済みのため補償せず見送り。同一コマンドでの再実行禁止を
# 記録に残したうえで、fatal にせず次の 1 件へ進む
NEEDS_REVIEW+=("#${ISSUE_NUMBER}: exit=11 third-party-parent — 旧親 #${OLD_PARENT} から DELETE 済み、第三者が別親を設定済み。同じコマンドで再実行禁止。stderr の実測親を確認しユーザー承認のうえ再実行すること")
continue
fi
# (a) それ以外(exit 1-8。9・10・11 を除く)は握り潰さず中断する。原因を解消してから再実行する
echo "エラー: #${ISSUE_NUMBER} の付け替えが exit ${status} で失敗した。中断する" >&2
exit "${status}"
done
if (( ${#SKIPPED[@]} > 0 )); then
echo "対象外(cross-repository 親): ${SKIPPED[*]}" >&2
fi
if (( ${#NEEDS_REVIEW[@]} > 0 )); then
echo "要確認事項(Step 9 のレポートへ転記すること):" >&2
printf ' %s\n' "${NEEDS_REVIEW[@]}" >&2
fi
reassign_one は1 件分の呼び出しであり、非ゼロ終了は当該 1 件の失敗として
関数の返り値へ伝播する。判定に必要な stderr は関数内で捕捉したうえで必ず再出力するため、
診断情報は失われない。呼び出し側ループは Step 9 の要確認事項へ記録したうえで、次の 3 通りに
振り分ける。(a) 解消可能な前提不備(exit 2 のうち gh/jq 不在・未認証・issue 取得失敗等。
9・10・11 を除く exit 1〜8 全般)は原因解消まで中断する。(b) 恒久的な対象外
(cross-repository 親。exit 9)は要確認事項へ記載して棚卸し対象から除外し、次の 1 件の
呼び出しへ進む。(c) 部分的に変更済みだが処理は継続してよいもの(exit 10 = DELETE 後の
補償復旧が成功し旧親へ復帰済み・exit 11 = DELETE 後に第三者が別親を設定済みで補償を見送り)は
要確認事項へ記録したうえで、fatal にせず次の 1 件へ進む。exit 10 / 11 を即 exit で中断すると
残りの REASSIGN_PLAN が未処理のまま止まり、Step 4 の孤児処理・Step 9 の報告経路自体が
スキップされる欠陥になるため、この 2 コードはループを止めない(終了コード表を参照)。
(a)/(b) の判定は stderr に reason=cross-repository-parent が出ているかで機械的に行う
(無ければ (a))。(c) は返り値がそのまま 10 / 11 であるため、判定に stderr のマーカーは不要。
(c) だけを継続対象にする理由(exit 3/4/5/7/8 は継続しない): 終了コード表を見ると
exit 3・4・5・7・8 も「呼び出し側の扱い」欄に「要確認事項へ記載」と書かれており、一見
(c) と同じに見える。しかし止める・止めないの分岐点は「要確認事項へ記載するか」ではなく
当該 1 件の終端状態が実測で確定しているかである。exit 10 / 11 は補償復旧ルーチンが
実状態を再取得した末に「旧親配下」「第三者親配下」という確定した終端状態へ着地しており、
次の 1 件へ進んでも新たな破壊は生じない。対して exit 3(DELETE 失敗)・4(POST 失敗)・
5(事後確認不一致)・7(POST 時点のレース)・8(補償も失敗し状態不明/孤児)は、無変更・
孤児化・状態不明のいずれかであり、原因(gh 認証切れ・API 障害・レース条件)が
後続の全件に共通して波及する可能性が高いため、1 件で切り上げて原因究明を優先する。
この境界線は exit 10 / 11 に限定したものであり、3/4/5/7/8 へ安易に拡張しない。
引数
| 引数 | 必須 | 意味 |
|---|---|---|
--issue | 必須 | 付け替え対象の issue 番号 |
--new-parent | 必須 | 付け替え先の issue 番号。DELETE の前に新親を GET し、存在すること・--issue 自身でないこと(自己参照)・対象 issue と同一リポジトリにあることを検証する。いずれかを満たさない場合はDELETE を 1 件も撃たずに exit 1(自己参照)/ exit 2(存在しない・別リポジトリ)で無変更終端する |
--old-parent | 任意 | 現在の親。Step 2 でユーザーが承認した旧親を渡す。実測した現在の親と食い違う場合は何も変更せず exit 6 で停止する(承認外の親子関係を壊さないため)。省略時は「孤児である」ことを承認した意味になり、実測で親が居れば同じく exit 6 で停止する |
--repo | 任意 | owner/name。対象 issue(--issue)の所在であり、親(--old-parent / --new-parent)の所在ではない。省略時は cwd の git remote から解決。--repo は対象 issue の GET だけでなく DELETE / POST を含む全 API パスを切り替えるため、親が別リポジトリにあるからといって親リポジトリの値を入れてはならない(入れると親リポジトリ側の同番号 issue を誤操作する) |
終了コードと result= 行
stdout 最終行が result=<state> issue=<n> new_parent=<n> old_parent=<n|-> の形式で
機械可読な内訳を返す。非ゼロ終了は 1 件も握り潰さず、Step 9 の完了レポートの
「要確認事項」へ必ず記載する。
| 終了コード | state | 意味 | 呼び出し側の扱い | 変更の有無 |
|---|---|---|---|---|
| 0 | reassigned | DELETE→POST を実施 | 「付け替え」件数へ計上 | 変更あり(成功) |
| 0 | already-attached | 既に新親配下(no-op) | 件数へ計上しない | 無変更(no-op) |
| 0 | posted-only | 旧親配下になく POST のみ | 「孤児の再配置」件数へ計上(Step 4 と同一スクリプト) | 変更あり(POST のみ) |
| 1 | — | 引数・使い方エラー(--new-parent の自己参照を含む) | 実行者の誤り。修正して再実行 | 無変更(API 未実行) |
| 2 | — | 前提不備。2 類型が混在し、stderr のマーカー行で機械的に判定する: reason=cross-repository-parent が出ていれば (b)、無ければ (a)。(a) 解消して再実行できるもの(gh/jq 不在・未認証・issue 取得失敗・新親が存在しない・新親が Pull Request・新親が別リポジトリ)と、(b) 恒久的に対象外の cross-repository 親(同一コマンドの再実行では解決しない。親リポジトリ側で親リンクが外れるまで本スキルでは処理できない) | (a) は原因を解消して再実行。(b) は要確認事項へ記載し、棚卸し対象から除外する(Step 2 参照) | 無変更(DELETE / POST 未実行) |
| 3 | — | DELETE 失敗(gh の非ゼロ終了)。POST は実行していない。 DELETE の応答だけを信頼せず実状態を再取得・安定確認したうえで、実測でも旧親配下のままであることを確認して初めて確定する(応答取得だけが失敗しサーバー側では成立していた場合は孤児として POST 工程へ進むため、この exit には到達しない) | 要確認事項へ記載。旧親配下のまま | 無変更(実測で確認済み。DELETE 失敗・旧親配下のまま) |
| 4 | — | POST 失敗(孤児経路のみ。DELETE を伴わないため孤児化リスク自体は無い)。POST の応答だけを信頼せず実状態を再取得・安定確認したうえで、実測でも孤児のままであることを確認して初めて確定する(応答取得だけが失敗しサーバー側では成立していた場合は posted-only として exit 0 になるため、この exit には到達しない) | 要確認事項へ記載 | 無変更(実測で確認済み。DELETE 未実行) |
| 5 | — | 事後確認で新親配下に見つからない(別リポジトリの親配下にある場合を含む) | 要確認事項へ記載。手動で実状態を確認 | 変更あり(DELETE / POST は実行済み・実状態の手動確認が必要) |
| 6 | — | 承認された旧親と実測が食い違う。何も変更していない | 要確認事項へ記載。同じコマンドで再実行してはならない。 stderr が示す実測の親をユーザーへ提示して承認を得たうえで、--old-parent にその値を入れて再実行する | 無変更 |
| 7 | — | POST 時点で別の親が付いていたレース。DELETE 未実行のため無変更 | 要確認事項へ記載。実測し直して承認を取り直したうえで再実行する | 無変更(DELETE 未実行) |
| 8 | — | 次の 3 系統いずれかの実状態再取得・安定確認が孤児のまま/状態不明/第三者の親配下で終端した: (1) DELETE 後の POST 失敗に対する補償復旧(補償 POST 自体の失敗・復旧用の再取得失敗・補償後の検証不一致・反映遅延の再確認不一致のいずれか。reason=compensation-post-failed(実測でも孤児)/ reason=compensation-post-failed-third-party-parent(補償 POST 失敗中に第三者が別の親を設定済みと実測)で内訳を判別)、(2) DELETE 失敗時の実状態再取得(DELETE はエラー応答だったがサーバー側で成立していた可能性があるため、応答だけで exit 3 と断定せず再取得した結果、再取得自体の失敗・不一致・不安定だった)、(3) 孤児経路の POST 失敗時の実状態再取得(POST はエラー応答だったがサーバー側で成立していた可能性があるため、応答だけで exit 4 と断定せず再取得した結果、再取得自体の失敗・不一致・不安定だった)。(2)(3) はいずれも reason=recovery-state-unknown を出す。(1) の再取得系失敗も同じ reason=recovery-state-unknown を出す | 要確認事項へ記載。いずれも無変更ではない(実測で確定できなかった状態不明)。 実状態を確認し必要なら手で紐付け直す。同一コマンドの再実行では回復しない | (1) 部分変更(旧親から外れ、新親にも付いていない。第三者配下の場合を除く)。(2)(3) 状態不明(DELETE / POST がサーバー側で成立していたか未確定のため、無変更とも部分変更とも断定しない) |
| 10 | restored | DELETE 後の POST 失敗に対する補償復旧が成功し、実測で旧親配下へ復帰した(本来の目的だった新親への付け替えは未達) | 要確認事項へ記載(本来の付け替えが未達のため)。件数へは計上しない | 変更あり(旧親へ復帰。新親への付け替えは不成立) |
| 11 | — | 実状態を再取得したところ、第三者が別の親を設定済みだったため補償せず見送った(reason=third-party-parent)。次のいずれかから到達する: (a) DELETE 後の POST 失敗を受けての再取得、(b) DELETE 失敗そのものを受けての再取得(DELETE の応答だけで exit 3 と断定せず再取得した結果、第三者の親配下と判明した場合)。新親配下の場合はこの判定より先に成功経路(exit 0 / result=reassigned)へ倒すため、この exit 11 に到達するのは実測が旧親でも新親でもない別の親だった場合に限る | 要確認事項へ記載。同じコマンドで再実行してはならない。 stderr が示す実測の親をユーザーへ提示して承認を得たうえで再実行する | 変更あり(旧親からは既に DELETE 済み。「補償 POST を撃たず fail-closed で停止」は新親への POST 側の話であり、DELETE は exit 11 に到達する時点で既に成立している(第三者の親配下に見えている以上、サーバー側のデータモデル上、旧親からは既に外れている)。 実測では第三者が設定した別親配下にある) |
スクリプト自身は 0〜8・10・11 を返す(9 は使わない)。 reassign-lib.sh の
reassign_one ラッパは、これに加えて 9 = 恒久的に対象外(cross-repository 親) を返す。9 はスクリプトの終了コードではなく、ラッパが stderr の
reason=cross-repository-parent マーカーを判定して合成する値であり、呼び出し側ループが
「棚卸し対象から除外して次の 1 件へ進む」のシグナルとして使う。マーカーの判定には
スクリプトの stderr が必要なため、ラッパは stderr をファイルへ捕捉したうえで必ず再出力する
(診断情報は握り潰さない)。10・11 はラッパの「その他はスクリプトの終了コードをそのまま返す」
契約により無加工で透過するため、reassign-lib.sh 側の変更は不要。
DELETE 后 POST 失败时补偿恢复的约定:对新父级进行预验证,可以在 DELETE 前排除“可预先判断的拒绝条件”,但 DELETE 与 POST 之间的竞争条件和临时 5xx 无法预先判断,仍是残余风险。如果 POST 失败后立即终止,目标 issue 会一直处于孤儿状态,因此检测到 POST 失败时,先重新获取目标 issue 的实际状态,再分支处理(先确认实测状态,再采取行动):
- 实测的父级是新父级 → POST 是假阴性。按通常的成功终止处理(
exit 0/result=reassigned)。 但是,如果在预期为旧父级或孤儿的重新确认过程中首次观察到新父级,则以新父级为预期值再获取 1 次,只有连续 2 次观察到该 issue 位于新父级下时,才确认reassigned。 如果不匹配或重新获取失败,不得按成功处理,而以exit 8(状态未知)终止 - 实测为孤儿 → 单次读取无法排除观察到的是 DELETE/POST 生效延迟导致的过渡状态,因此在执行补偿 POST 写入前,间隔较短时间后重新获取,只有连续 2 次观察到没有父级,才确认其为稳定孤儿。确认稳定后,执行补偿 POST。将其恢复到旧父级属于已批准操作的逆操作,因此在批准范围内。成功后重新进行事后确认,以
exit 10/result=restored终止。补偿 POST 本身也失败的多重故障,或重新获取、事后确认、稳定性确认失败导致的状态未知情况,不执行写入,并以exit 8终止 - 实测发现第三方已设置了其他父级(包括其他仓库) → 不执行补偿 POST,以
exit 11进行 fail-closed 停止。不覆盖第三方已确定的父子关系
DELETE 失败及孤儿路径 POST 失败时重新确认实际状态:上述处理适用于 DELETE 成功后 POST 失败(部分变更)时的补偿恢复;DELETE 本身失败(gh 非零退出,且没有 reason 类标记)以及孤儿路径(不涉及 DELETE)的 POST 失败也按相同方式处理。
gh api 即使以退出代码 0 返回,也可能只有响应获取失败;而非零退出并不能保证“服务器端未处理成功”(响应获取失败也可能导致非零退出)。
因此,仅凭 DELETE / POST 非零退出就立即判定为 exit 3 / exit 4(无变更),可能会在服务器端实际已处理成功时造成误报。
对称地,先重新获取实际状态,再分支处理:
- DELETE 失败 → 重新获取实际状态;如果稳定确认仍位于旧父级下,则以
exit 3(无变更)终止。如果稳定确认其为孤儿,则视为 DELETE 已在服务器端生效,并直接进入 POST 阶段(关联到新父级)。如果稳定确认其位于新父级下,须先于第三方父级判断进行判定,并视为 DELETE→POST 已成功(或并行执行已将其关联到获批的新父级),按现有成功路径以result=reassigned和exit 0终止(首次读取时直接观察到新父级,也应与稳定确认过程中观察到新父级时采用相同的成功终止方式,避免仅因观察时机不同而误判为未获批的第三方父级并返回exit 11)。除此之外,如果第三方已设置其他父级,则以exit 11reason=third-party-parent终止(与现有补偿恢复路径相同)。重新获取失败、不匹配或状态不稳定时,以exit 8reason=recovery-state-unknown终止 - 孤儿路径(不涉及 DELETE)的 POST 失败 → 重新获取实际状态;如果稳定确认其仍为孤儿,则以
exit 4(无变更)终止。如果稳定确认其位于新父级下,则视为 POST 是假阴性,以result=posted-only和exit 0终止。此路径不涉及 DELETE,也不存在孤儿化风险,因此不会执行补偿写入(POST),仅进行判定即可完成。基于错误消息的竞争条件检测(exit 7。"only have one parent")优先于重新获取。如果不符合该条件(重新获取失败、不匹配、状态不稳定,或存在与消息不符的第三方父级),则以exit 8reason=recovery-state-unknown终止
预验证与事后报告的约定:对于可预先判断的拒绝条件(新父级不存在、自引用、属于其他仓库),不执行 DELETE,并以 exit 1 / 2 作为无变更终止。另一方面,对于无法预先判断的拒绝条件(预先 GET 与 DELETE / POST 之间第三方更改状态的竞争条件、循环引用、新父级不接受 sub-issue 等),约定事后以 exit 4 / exit 7 / exit 8 报告,预验证不能取代这一约定。只有 DELETE 后 POST 失败(可能导致孤儿化的部分变更)不应立即通过 exit 报告,而是先经过上述补偿恢复例程,再以 exit 8 / 10 / 11 之一结束。
GET 次数:各路径的 GET 次数如下(关联、更改归属路径包含预验证新父级的 1 次 GET)。
| 路径 | GET 次数 |
|---|---|
| 正常关联(reassigned) | 3 |
| 孤儿重新归属(posted-only) | 3 |
| already-attached(no-op) | 1 |
| 批准不匹配(exit 6) | 1 |
预验证最多执行 1 次 GET,update-issue-tree 每次运行的调用次数为几十次规模。相较于已认证 REST 的 5,000 req/h,这一开销可以忽略。与之相比,所防止的是不可逆的孤儿化,值得付出这点代价。
孤儿路径(不涉及 DELETE、也不存在孤儿化风险的路径)同样执行验证,是为了有意统一不同路径的约定:“可预先判断的拒绝条件必须以无变更方式终止”;因此,如果新父级不存在,即使是孤儿路径也会以 exit 2 终止。
Step 4: 重新归属孤儿 issue
将没有关联到任何父级的孤儿 issue 关联到合适的 Phase 父级。
省略 --old-parent 并调用同一脚本(跳过 DELETE,仅执行 POST)。
对于 Phase 不明的 issue,阅读标题和正文后判断;如果无法判断,则向用户确认。
此围栏可独立于 Step 3 的执行结果运行。 围栏可能在独立 shell 中 运行,因此应按不会继承 Step 3 定义的函数和变量来编写。前置内容与 Step 3 相同。
# reassign-lib.sh を 3 レイアウトから解決して source する。
# **この前置きは Step 3 / Step 4 の両フェンスが同一の内容で持つ。** 手順書のコードフェンスは
# ブロックごとに独立シェルで実行され得るため、片方のフェンスで定義した関数・変数がもう一方へ
# 継承される保証がない(Issue #372 / PR #374 codex-review P1)。
REASSIGN_LIB=""
for CANDIDATE in \
"skills/update-issue-tree/scripts/reassign-lib.sh" \
".agents/skills/update-issue-tree/scripts/reassign-lib.sh" \
".claude/skills/update-issue-tree/scripts/reassign-lib.sh"; do
# 存在確認は -f のみで行う(-x にすると、npx skills add 等の vendoring で
# 実行ビットが落ちたファイルを「存在しない」と誤検知し、3 レイアウトいずれにも
# 見つからないという誤ったエラーメッセージになる)
if [[ -f "${CANDIDATE}" ]]; then
REASSIGN_LIB="${CANDIDATE}"
break
fi
done
if [[ -z "${REASSIGN_LIB}" ]]; then
echo "エラー: reassign-lib.sh が見つからない(3 レイアウトいずれにも存在しない)" >&2
exit 1
fi
# ライブラリは対の reassign-sub-issue.sh の存在まで確認したうえで、reassign_one /
# require_plan_array と REASSIGN_SCRIPT を定義する。欠落していれば非ゼロを返すので中断する。
# このフェンスで set -euo pipefail を使ってはならない(理由は reassign-lib.sh 冒頭のコメント参照。
# set -e があると reassign_one 内の `status=$?` 退避に到達せず、Issue #335 の欠陥が再発する)
source "${REASSIGN_LIB}" || exit 1
# Step 3 と同じガードを同じ関数で行う(非対称を作らない)
require_plan_array ORPHAN_PLAN || exit 1
SKIPPED_ORPHANS=()
NEEDS_REVIEW_ORPHANS=()
for ENTRY in "${ORPHAN_PLAN[@]}"; do
# ENTRY は "<orphan-issue> <phase-parent>" 形式
IFS=' ' read -r ORPHAN_NUMBER PHASE_NUMBER <<< "${ENTRY}"
# Step 3 と同じ理由で `if ...; then ... fi` は使わない(`fi` 直後の $? は 0 になる)
status=0
reassign_one --issue "${ORPHAN_NUMBER}" --new-parent "${PHASE_NUMBER}" || status=$?
if (( status == 0 )); then
continue
fi
if (( status == 9 )); then
SKIPPED_ORPHANS+=("${ORPHAN_NUMBER}")
continue
fi
if (( status == 10 )); then
# Step 3 と同じ (c) 扱い。孤児経路は --old-parent を渡さないため通常は起こり得ないが、
# ループ構造を Step 3 と非対称にしないため同じ分岐を持つ
NEEDS_REVIEW_ORPHANS+=("#${ORPHAN_NUMBER}: exit=10 restored — 補償復旧が発生。Phase 親 #${PHASE_NUMBER} への紐付けは未達。実状態を確認すること")
continue
fi
if (( status == 11 )); then
NEEDS_REVIEW_ORPHANS+=("#${ORPHAN_NUMBER}: exit=11 third-party-parent — 第三者が別親を設定済み。同じコマンドで再実行禁止。stderr の実測親を確認しユーザー承認のうえ再実行すること")
continue
fi
echo "エラー: #${ORPHAN_NUMBER} の再配置が exit ${status} で失敗した。中断する" >&2
exit "${status}"
done
if (( ${#SKIPPED_ORPHANS[@]} > 0 )); then
echo "対象外(cross-repository 親): ${SKIPPED_ORPHANS[*]}" >&2
fi
if (( ${#NEEDS_REVIEW_ORPHANS[@]} > 0 )); then
echo "要確認事項(Step 9 のレポートへ転記すること):" >&2
printf ' %s\n' "${NEEDS_REVIEW_ORPHANS[@]}" >&2
fi
函数和循环结构与 Step 3 相同,退出代码的处理方式也一致
((a) 仅对可解决的前置条件问题中断,直到问题解决;(b) 将永久性不适用项(exit 9)记录为待确认事项,然后继续处理下一项;(c) 对已部分变更但可以继续的项目(exit 10 / 11),也记录为待确认事项,不将其视为 fatal,并继续处理下一项。(b) 的判定通过函数捕获的 stderr
中的 reason=cross-repository-parent 标记机械化完成,并以返回值 9 传递给调用方。
请参阅退出代码表)。
Step 5: 必要时新建 Phase 父级
如果无法纳入现有 Phase 的新任务较多,则创建新的 Phase 父级 issue 并关联到根 issue。
(此处不使用 reassign-sub-issue.sh 执行 POST。因为这是刚创建、显然没有父级的 issue 的单次 POST,不属于 DELETE 路径,也不需要进行幂等性判定。)
# phase ラベルが存在しないリポジトリでは issue 作成が失敗するため、必ず事前作成する
# (作成済みの場合は失敗を無視して続行する)
gh label create "phase:N" --color "0075ca" 2>/dev/null || true
# gh issue create は URL を出力する(--json 非対応)。URL 末尾から番号を抽出する
NEW_PHASE_URL=$(gh issue create \
--title "feat(phase-N): Phase N タイトル" \
--label "phase:N" \
--body "$(cat <<'EOF'
## 概要
Phase N の実装タスクをまとめる親 issue。
## タスク一覧
| Issue | タイトル | 分解 |
|-------|---------|------|
EOF
)")
NEW_PHASE_NUMBER=$(printf '%s' "${NEW_PHASE_URL}" | grep -oE '[0-9]+$')
# ルートへ紐付け。sub_issue_id は issue 番号ではなく database id を渡す(GitHub sub-issues API 仕様)
NEW_PHASE_ID=$(gh api "repos/{owner}/{repo}/issues/${NEW_PHASE_NUMBER}" --jq '.id')
gh api \
--method POST \
"repos/{owner}/{repo}/issues/${ROOT_NUMBER}/sub_issues" \
-F "sub_issue_id=${NEW_PHASE_ID}"
Step 6: 同步 phase 标签
确认各 issue 的 phase 标签与父级 Phase 一致,并修正不一致的标签。
# ラベルを追加
gh issue edit "${ISSUE_NUMBER}" --add-label "phase:1"
# 古いラベルを削除
gh issue edit "${ISSUE_NUMBER}" --remove-label "phase:0"
Step 7: 将超出粒度标准的 issue 拆分为 sub-issue
盘点过程中,如果判断 issue 的实现时间超过 ${GRANULARITY}(在 Step 1 中确定;默认 2h),则按照与 create-issue-tree 相同的粒度标准将其拆分为 sub-issue。
(此处的 POST 也不使用 reassign-sub-issue.sh。原因与 Step 5 相同:这是对新创建的无父级 issue 执行单次 POST。)
# phase ラベルが存在しない場合に備えて事前作成する(作成済みなら no-op)
gh label create "phase:N" --color "0075ca" 2>/dev/null || true
# sub-issue を作成(URL 末尾から番号を抽出)
SUB_URL=$(gh issue create \
--title "feat: サブタスク名" \
--label "phase:N" \
--body "...")
SUB_NUMBER=$(printf '%s' "${SUB_URL}" | grep -oE '[0-9]+$')
# 親 issue へ紐付け(sub_issue_id は database id)
SUB_ID=$(gh api "repos/{owner}/{repo}/issues/${SUB_NUMBER}" --jq '.id')
gh api \
--method POST \
"repos/{owner}/{repo}/issues/${ISSUE_NUMBER}/sub_issues" \
-F "sub_issue_id=${SUB_ID}"
Step 8: ルート issue 本文の管理ブロックを更新する(既存本文は保持)
ルート issue 本文のうち本スキルが管理する範囲だけを実ツリーから再生成して差し替える。
それ以外の本文(概要・運用・手書きの計画や判断根拠・追記)は 1 行も失わない。本文全体を
固定テンプレートで書き戻してはならない(人が書いた内容が棚卸しのたびに消え、件数・日付の
プレースホルダーが本文へ到達し得るため。Issue #545)。処理は scripts/update-root-body.sh
に集約されている。
管理範囲と不変条件
| 領域 | マーカー(行全体で一致・コードフェンス外のみ有効) | 更新規則 |
|---|---|---|
| Phase 別表 | / | マーカー間を毎回全置換(ルート直下の sub-issue 全件の表と、直下に open の子を持つ節点の詳細表) |
| 棚卸し履歴 | / | 追記専用。既存行は保持し、今回の日付と 5 件数の 1 行を末尾へ足す(直前行と同一なら足さない) |
| granularity | `` | 先頭に 1 行だけ維持する。値は GRANULARITY の確定値 |
- 既存本文から消してよいのは「phase-plan マーカー間の行」と「granularity マーカー行」だけ
## 概要・## 運用は管理しない。存在すれば逐語で保持し、存在しなくても追加しない (本文が空のときだけ骨格を生成する)- 表・件数・日付は実ツリーと検証済み引数からだけ生成する。5 件数の引数は既定値なしの必須で、 プレースホルダーが残っていれば API 呼び出し前に exit 1 で止まる
- issue タイトルは非信頼データ。改行は空白 1 つに、
&・<・>・バッククォート・バックスラッシュ・|は HTML 文字参照へ変換して書き込む(create-issue-tree と同じ変換)。タイトルが管理マーカーを偽装できず、create-issue-tree の--root再マージも判定範囲の記号で中止しない。旧形式(\|等)の行は管理マーカー区間内であれば次回の実行で書き直される - マーカーの不整合(片方のみ・重複・逆順・入れ子)と、閉じていないコードフェンスは編集せず exit 4 で止まる (閉じていないフェンスは以降を全部コード扱いにして管理ブロックを二重追記するため)
マーカーが無い既存本文の移行(現存するルートはすべてこの状態): 既存行は捨てない。
## Phase 別実装計画節があれば、見出し直後へマーカー付きの生成ブロックを挿入する。同節内の 見出し直後〜最初の H3 までの先頭部分と### Phaseで始まる H3 は旧生成物の候補として 削除せず、<details>(要約に退避日と「確認のうえ不要なら削除」を明記)へ逐語で移す。 それ以外の H3(### 実装ラン (...)等)は元の位置・内容のまま残す- 節が無ければ本文末尾へ
## Phase 別実装計画とマーカー付きブロックを追記する - 棚卸し履歴は
## 棚卸しで実施した整理(update-issue-tree 実行履歴)節を Phase 別実装計画の直前へ挿入する。 既存の手書きの棚卸し節には触れない - 退避が起きたら stdout 最終行の
migration=phase-plan-archivedを Step 9 の要確認事項へ転記する
書き込み前の確認と書き込み: 取得した本文と編集直前に再取得した本文が異なれば並行編集として
exit 5 で止まる。変更が無ければ result=unchanged で編集しない。--dry-run を付けると
マージ後の本文を stdout へ出し、書き込み系の gh を一切呼ばない(初回の移行前に推奨)。
件数変数の確定手順(各コードフェンスは独立したシェルで実行され得るため、Step 3〜7 のフェンス内の 変数は Step 8 へ引き継がれない。実績値は会話上で集計し、下記フェンスの先頭へ数値を直接書いた代入行として 追記してから実行する。推測・既定値・プレースホルダーのまま実行しない):
| 変数 | 確定する値 | 集計元 |
|---|---|---|
ROOT_NUMBER | ルート issue 番号 | 引数 |
GRANULARITY | 粒度(2h 等。Nh 形式) | 引数の確定値(既定 2h) |
REASSIGNED_COUNT | closed 親下の残置 issue 付け替えの成功件数 | Step 3 で reassign-sub-issue.sh が result=reassigned / posted-only を返した回数 |
ORPHAN_COUNT | 孤児 issue 再配置の成功件数 | Step 4 で同様に成功した回数 |
LABEL_SYNC_COUNT | phase ラベル同期の成功件数 | Step 6 で実際にラベルを付与・変更できた issue 数 |
NEW_PHASE_COUNT | 新設した Phase 親の件数 | Step 5 で作成と紐付けが成功した件数 |
SPLIT_COUNT | sub-issue 分解の成功件数 | Step 7 で分解(作成と紐付け)まで成功した issue 数 |
- 該当 Step を実行しなかった、または対象が無かった場合は
0を代入する(未設定のまま放置しない) - 失敗・見送り(非ゼロ終了、exit 10 / 11 を含む)は件数へ計上しない(Step 9 の集計規則と同じ)
- 代入例(値は実績に置き換える):
ROOT_NUMBER=254 GRANULARITY=2h REASSIGNED_COUNT=1 ORPHAN_COUNT=0 LABEL_SYNC_COUNT=3 NEW_PHASE_COUNT=0 SPLIT_COUNT=0をexport付きでフェンス先頭に置く
各コードフェンスは独立したシェルで実行され得るため、このフェンスは Step 3 / Step 4 と同形に スクリプトを 3 レイアウトから自己完結的に解決する。件数の変数が未設定なら起動前に停止する。
# Step 1〜7 の確定値。未設定ならここで停止する(プレースホルダーを本文へ流さない)
: "${ROOT_NUMBER:?}" "${GRANULARITY:?}" "${REASSIGNED_COUNT:?}" "${ORPHAN_COUNT:?}" \
"${LABEL_SYNC_COUNT:?}" "${NEW_PHASE_COUNT:?}" "${SPLIT_COUNT:?}"
UPDATE_ROOT_BODY=""
for CANDIDATE in \
"skills/update-issue-tree/scripts/update-root-body.sh" \
".agents/skills/update-issue-tree/scripts/update-root-body.sh" \
".claude/skills/update-issue-tree/scripts/update-root-body.sh"; do
# 存在確認は -f のみ(vendoring で実行ビットが落ちても bash 経由で起動できる)
if [[ -f "${CANDIDATE}" ]]; then
UPDATE_ROOT_BODY="${CANDIDATE}"
break
fi
done
if [[ -z "${UPDATE_ROOT_BODY}" ]]; then
echo "エラー: update-root-body.sh が見つからない(3 レイアウトいずれにも存在しない)" >&2
exit 1
fi
# echo を最終コマンドにしない。非ゼロ終了はそのままフェンスの終了ステータスにする
status=0
bash "${UPDATE_ROOT_BODY}" \
--root "${ROOT_NUMBER}" --granularity "${GRANULARITY}" \
--reassigned "${REASSIGNED_COUNT}" --orphans "${ORPHAN_COUNT}" \
--labels "${LABEL_SYNC_COUNT}" --new-phases "${NEW_PHASE_COUNT}" \
--split "${SPLIT_COUNT}" || status=$?
if (( status != 0 )); then
echo "exit=${status}" >&2
exit "${status}"
fi
終了コード(stdout 最終行は result=<updated|unchanged|dry-run> root=<n> migration=<none|phase-plan-archived|inserted> phases=<k>)
| exit | 意味 | 本文の変更 |
|---|---|---|
| 0 | 成功(updated のみ変更あり) | updated のみ |
| 1 | 引数エラー(件数のプレースホルダー残りを含む) | なし |
| 2 | 前提不備(gh / jq 不在・未認証・ルート取得失敗・ルートが PR) | なし |
| 3 | ツリー取得失敗・深さ超過(8 段)・表の行数不一致・65,536 文字超 | なし |
| 4 | 管理マーカーの不整合、または本文のコードフェンスが閉じていない | なし |
| 5 | 並行編集を検知 | なし |
| 6 | gh issue edit が失敗 | 不明(実状態を確認する) |
| 7 | 事後確認の不一致(再取得した本文全体が送信した本文と一致しない。末尾改行・CR の差は無視) | あり(編集済み) |
非ゼロ終了は握り潰さず Step 9 の要確認事項へ転記する。exit 6 / 7 は本文の実状態を確認する。 誤って更新した場合は GitHub の本文編集履歴から復元できる。
Step 9: 棚卸し結果を報告する
## update-issue-tree 完了レポート
### 対象ルート issue
- #N: タイトル
### 棚卸し実施内容
| 操作 | 件数 |
|------|------|
| closed 親下の残置 issue 付け替え | N 件 |
| 孤児 issue の再配置 | N 件 |
| phase ラベル同期 | N 件 |
| 新 Phase 親の新設 | N 件 |
| 粒度基準超 issue の sub-issue 分解 | N 件 |
### 現在の Phase 別サマリー
| Phase | 親 issue | open 件数 |
|-------|----------|----------|
| Phase 1 | #N | N 件 |
### ルート本文の更新結果
- result: updated / unchanged、migration: none / phase-plan-archived / inserted
### 要確認事項(自動配置できなかった issue)
- #N: タイトル — 確認理由
- 退避ブロック(`<details>`)の確認 / Step 8 の exit 5・6・7(該当時)
「closed 親下の残置 issue 付け替え」「孤児 issue の再配置」の件数は、Step 3 / Step 4 で
reassign-sub-issue.sh を呼んだ回数分の result= 行(reassigned / posted-only)から集計する。
すべての非ゼロ終了(exit 1〜8・10・11。今後コードが増えた場合も含む)は 1 件も件数へ含めず、
必ず「要確認事項」へ理由付きで記載する。とくに exit 8 は部分変更が残っているため、
報告を漏らすと壊れたツリーが放置される。exit 10(補償復旧成功)・exit 11(第三者が別親を設定済み)
も本来の付け替えは未達のため件数へは計上せず要確認事項へ記載する。exit 9・10・11 は Step 3 /
Step 4 のループ自体が fatal 扱いせず継続するため(終了コード表・上記ループを参照)、
これらは計画配列の残り全件を処理し終えたうえで Step 9 に到達する。Step 3 / Step 4 が stderr へ
出力する SKIPPED / NEEDS_REVIEW(および SKIPPED_ORPHANS / NEEDS_REVIEW_ORPHANS)の内容を
そのままこの節へ転記する。
検証
- ルート issue 本文の Phase 別表が更新されていることを確認する。あわせてマーカー外の節(概要・運用・手書き節)が更新前後で変わっていないこと、
YYYY-MM-DD・N 件等のプレースホルダーが本文に無いことを確認する(初回はupdate-root-body.sh ... --dry-runで出力を確認してから書き込む) - closed Phase 親の下に open issue が残置されていないことを確認する。ただし exit 10 (補償復旧成功)で要確認事項へ記録された issue は、承認範囲内の逆操作として旧親(closed) 配下へ意図的に復帰しているため、この確認の例外として扱う。 単独では「残置」に見えるので、 Step 9 の要確認事項の記録と突き合わせて exit 10 由来であることを確認する
- Step 3 / Step 4 で呼んだ
reassign-sub-issue.shの各回について、echo "exit=${status}"の値とresult=行を確認する。非ゼロ終了があれば Step 9 の要確認事項へ反映されているか確認する。 加えて、**exit 1〜8(9・10・11 を除く)**の fatal 系はコードブロック自体の終了ステータスが 非ゼロで返ること(echoを最後のコマンドにして握り潰していないこと)を確認する。 exit 9・10・11は fatal ではなくループ継続の契約であるため、コードブロックはそのまま 正常終了(終了ステータス 0)で最後のSKIPPED*/NEEDS_REVIEW*echo まで到達すること、 かつREASSIGN_PLAN/ORPHAN_PLANの残り全件が処理されていることを確認する
# 全 sub-issues の state を確認
gh api "repos/{owner}/{repo}/issues/${ROOT_NUMBER}/sub_issues" \
--jq '.[] | {number: .number, state: .state, title: .title}'
# Phase 親の sub-issues も確認
gh api "repos/{owner}/{repo}/issues/${PHASE_NUMBER}/sub_issues" \
--jq '.[] | {number: .number, state: .state}'
# phase ラベルの同期確認(各 Phase 親直下で確認)
gh api "repos/{owner}/{repo}/issues/${PHASE_NUMBER}/sub_issues" \
--jq '.[] | {number: .number, labels: [.labels[].name]}'
よくある失敗
| 問題 | 回避策 |
|---|---|
| 付け替えの DELETE が 404 になり、続く POST が 422 で失敗する | 削除のパスだけ単数形 sub_issue。複数形 sub_issues は 404 になり、旧親から外れないまま POST するため Sub issue may only have one parent で必ず失敗する(reassign-sub-issue.sh は DELETE 失敗時に POST へ進まないため、この連鎖失敗自体は起きない。手動で gh api を直接叩く場合の注意として記載を残す) |
| 管理ブロック内を手で編集したが次回の棚卸しで上書きされた | マーカー間は毎回再生成される。残したい内容はマーカーの外へ書く |
| 管理マーカーを片方だけ消して Step 8 が exit 4 になる | 両方を戻すか両方とも消す(マーカーを消すと次回は移行扱いになり、旧ブロックは <details> へ退避される) |
--granularity に 2 h・2・0h 等を渡して中断される | 正整数+h 形式(^[1-9][0-9]*h$。例: 2h・4h)で指定する |
注意事項
- 棚卸し前に変更内容をユーザーに提示して確認を取る(Step 2 参照)
- ページネーション: sub-issues が 100 件を超える場合は
per_page=100&page=Nでページングして全件取得する(Step 1 のツリー全体取得に適用。reassign-sub-issue.shは対象 issue のparent_issue_urlを直接参照するため、付け替え判定自体にはページネーションが不要) - シェルコマンドの変数は必ず
"${var}"でクォートする(コマンドインジェクション対策) gh issue createは--json非対応。issue URL を stdout に出力するため、| grep -oE '[0-9]+$'で末尾の番号を抽出する- sub_issues API(POST / DELETE)の
sub_issue_idは issue 番号ではなく database id(GitHub 仕様)。gh api "repos/{owner}/{repo}/issues/<number>" --jq '.id'で id を取得してから渡す。番号をそのまま渡すと誤った issue を操作する/404 になる(reassign-sub-issue.shはこれを内部で解決するため、Step 3/4 で手動取得する必要はない) - 孤児 issue の Phase が判断できない場合は推測せずにユーザーへ確認する
- sub_issues の DELETE API(付け替え時に旧親から外す操作)はパスが単数形
sub_issueである点に注意し、操作対象の issue 番号を必ず確認してから実行する - ツリー更新後は implement-issue-tree が post-order DFS で正しく消化できる構造になっているか確認する
- Step 3 / Step 4 の付け替え処理は
scripts/reassign-sub-issue.shを使う。SKILL.md 本文へ素のgh apiDELETE/POST を書き戻さない(状態変数の受け渡しがコードフェンス境界で壊れるクラスの欠陥に戻るため。詳細はscripts/reassign-sub-issue.sh冒頭コメントを参照) - Step 8 は
scripts/update-root-body.shを使う。gh issue edit --bodyでルート本文全体を書き戻さない(手書きの本文が消えるため。詳細はscripts/update-root-body.sh冒頭コメントを参照)。誤更新時は GitHub の本文編集履歴から復元できる