Skip to content

Commit 8905101

Browse files
massu2002claude
andauthored
Issue Form と /issue が Issue の種別を付ける (#38)
直近の Issue 71 件はすべて種別なしだった。Form も /issue も種別を指定して いなかったためで、Organization に種別を足しただけでは使われない。 Experiment 種別を 2026-09-30 に足したので、Issue を作る 2 つの経路に 種別を入れる(development-docs の D10)。 種別は Form の type: 1 箇所に書く。Web の Form はそれで付く。CLI の issue-form.sh は Form の type: を読んで skeleton と check に表示し、 /issue は gh issue create --type に渡す。種別を書き写さないので、Form を 直せば両方が変わる。--type は gh 2.94.0 で入った(cli/cli#13057)。古い gh で unknown flag と出たら、--type を外さずに更新を依頼させる。 Form に存在しない種別を書いたときの挙動は公式文書に無い。種別の名前を Organization 側で変えたり無効にしたりしても Form は黙って残るので、監査 (org_audit.py)が Form の type: を Organization の有効な種別と照合する。 取れなかったときは「無い」と言わない。 実測: gh issue create --type Feature で作った #37 に種別が付いた(この Organization で最初)。テスト: 監査 42 件、Form と issue-form.sh 4 件 (gh を差し替え)すべて OK。壊す 9 通りすべてで落ちた。実 Organization への読み取り実行で所見 0 件。 Closes #37 Co-authored-by: Claude Opus 5.5 <[email protected]>
1 parent c62c402 commit 8905101

10 files changed

Lines changed: 240 additions & 12 deletions

File tree

‎.claude/commands/issue.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ allowed-tools: Bash(bash .claude/scripts/issue-form.sh:*), Bash(gh issue create:
2020
bash .claude/scripts/issue-form.sh skeleton <種別>
2121
```
2222

23-
標準出力が本文の雛形、標準エラーが title の接頭辞・必須ラベル・選択肢。
23+
標準出力が本文の雛形、標準エラーが title の接頭辞・Issue の種別・必須ラベル・選択肢。
2424

2525
## 3. 形式のルール
2626

@@ -73,7 +73,11 @@ bash .claude/scripts/issue-form.sh check <種別> <ファイル>
7373
`OK` になるまで作成しない。確認後、ユーザーの承認を得て作成する。
7474

7575
```bash
76-
gh issue create --repo MirumeAI/<repository> --title "<接頭辞><要約>" --body-file <ファイル>
76+
gh issue create --repo MirumeAI/<repository> --type <Issue の種別> --title "<接頭辞><要約>" --body-file <ファイル>
7777
```
7878

79+
`<Issue の種別>` は skeleton と check が表示した値をそのまま使う(正本は Form の `type:`)。
80+
`--type` を省くと種別の無い Issue になる。`unknown flag: --type` と出たら gh が古い(2.94.0 以降が必要)。
81+
`--type` を外して作らず、gh の更新をユーザーに依頼する。
82+
7983
発行された番号を報告する。Issue 番号が確定するまで Branch は作らない。

‎.claude/scripts/issue-form.sh‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
# issue-form.sh check <feature|bug|experiment> <file>
1010
#
1111
# ラベルをこのファイルへ書き写さない。常に正本から取得する。
12+
# Issue の種別(Organization の Issue Type)も Form の `type:` から取る。
1213

1314
set -euo pipefail
1415

@@ -74,13 +75,27 @@ options() {
7475

7576
title_prefix() { fetch_form "$1" | sed -n 's/^title: "\(.*\)"$/\1/p'; }
7677

78+
# Issue の種別。gh issue create の --type に渡す(省くと種別の無い Issue になる)
79+
issue_type() { fetch_form "$1" | sed -n 's/^type: *"\{0,1\}\([^"]*\)"\{0,1\} *$/\1/p'; }
80+
81+
type_line() {
82+
local t
83+
t="$(issue_type "$1")"
84+
if [ -n "$t" ]; then
85+
echo "Issue の種別: $t(gh issue create --type $t)"
86+
else
87+
echo "Issue の種別: なし(Form に type: が無い。--type は付けない)"
88+
fi
89+
}
90+
7791
skeleton() {
7892
local type="$1" req opt
7993
req="$(required_labels "$type")"
8094
opt="$(options "$type")"
8195

8296
echo "# --- ここから下が Issue 本文。この行と次の説明ブロックは本文に含めない ---" >&2
8397
echo "title の接頭辞: $(title_prefix "$type")" >&2
98+
type_line "$type" >&2
8499
echo "必須ラベル:" >&2
85100
echo "$req" | sed 's/^/ - /' >&2
86101
if [ -n "$opt" ]; then
@@ -167,6 +182,7 @@ check() {
167182

168183
[ "$ng" -eq 0 ] || exit 1
169184
echo "OK: 見出し・必須項目・選択肢すべて Form の定義と一致しています"
185+
type_line "$type"
170186
}
171187

172188
[ $# -ge 1 ] || usage

‎.github/ISSUE_TEMPLATE/bug.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
name: Bug
22
description: 不具合・想定外動作の修正
33
title: "[Bug] "
4+
type: Bug
45
body:
56
- type: markdown
67
attributes:

‎.github/ISSUE_TEMPLATE/experiment.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
name: Experiment
22
description: AI・アルゴリズム・性能評価などの実験
33
title: "[Experiment] "
4+
type: Experiment
45
body:
56
- type: markdown
67
attributes:

‎.github/ISSUE_TEMPLATE/feature.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
name: Feature
22
description: 新機能の追加・既存機能・ドキュメントの改善
33
title: "[Feature] "
4+
type: Feature
45
body:
56
- type: markdown
67
attributes:

‎.github/workflows/basic-checks.yml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ jobs:
5454
- name: Organization 監査の判定
5555
run: python scripts/ci/test_org_audit.py
5656

57+
# **Issue の種別を付ける経路が崩れていないこと。**
58+
# 直近の Issue 71 件はすべて種別なしだった。 Form も /issue も種別を
59+
# 指定していなかったためで、 種別を足しただけでは使われない。
60+
# Form の type: と、 それを読む issue-form.sh を確かめる(gh は差し替え)。
61+
- name: Issue Form の種別
62+
run: python scripts/ci/test_issue_form.py
63+
5764
# **秘密情報の走査が、 拡張子で取りこぼさないこと。**
5865
# 複製されていた旧版は `.env` や拡張子の無いファイルを丸ごと
5966
# 飛ばしていた(実在の `configs/active_profile` が該当)。

‎CLAUDE.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,15 @@ MirumeAI 標準開発ルールに従ってください。
1919
```text
2020
.github/
2121
├── ISSUE_TEMPLATE/
22-
│ ├── feature.yml … 新機能・機能改善・ドキュメント改善
23-
│ ├── bug.yml … 不具合修正
24-
│ ├── experiment.yml … AI・アルゴリズム・性能評価
22+
│ ├── feature.yml … 新機能・機能改善・ドキュメント改善(種別 Feature)
23+
│ ├── bug.yml … 不具合修正(種別 Bug)
24+
│ ├── experiment.yml … AI・アルゴリズム・性能評価(種別 Experiment)
2525
│ └── config.yml … blank_issues_enabled: false
2626
└── PULL_REQUEST_TEMPLATE.md
2727
```
2828

2929
ここに置いたテンプレートは、各 Repository に同種の独自テンプレートが**無い場合のみ**既定値として使われます。
30+
種別(`type:`)は Organization の Issue Types にあるものだけを書きます。CLI の `/issue` も Form の `type:` を読んで `--type` に渡します。
3031
各製品 Repository へ複製しないでください。
3132

3233
## 変更時の手順

‎scripts/ci/org_audit.py‎

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -159,15 +159,27 @@ def _documented_case_count(text: str) -> "int | None":
159159
return int(m.group(1)) if m else None
160160

161161

162+
def _form_types(repo_root: Path) -> dict:
163+
"""Issue Form ごとの `type:`(無ければ None)。 `config.yml` は Form ではない。"""
164+
out = {}
165+
for f in sorted((repo_root / ".github" / "ISSUE_TEMPLATE").glob("*.yml")):
166+
if f.name == "config.yml":
167+
continue
168+
m = re.search(r"^type:\s*\"?([^\"\n]+?)\"?\s*$",
169+
f.read_text(encoding="utf-8"), re.M)
170+
out[f.name] = m.group(1) if m else None
171+
return out
172+
173+
162174
def audit_org(gh: Gh) -> dict:
163175
"""Organization 側の実測値。"""
164176
org = gh.get(f"/orgs/{ORG}", "Organization の Plan") or {}
165177
out = {
166178
"plan": (org.get("plan") or {}).get("name"),
167-
# None は「取れなかった」。 {}(定義が無い)と区別する
179+
# None は「取れなかった」。 {} や [](定義が無い)と区別する
168180
"properties": None,
169-
"issue_types": [],
170-
"issue_fields": [],
181+
"issue_types": None, # 有効なものだけ
182+
"issue_fields": None,
171183
"teams": {},
172184
}
173185
schema = gh.get(f"/orgs/{ORG}/properties/schema", "Custom Properties の定義")
@@ -184,7 +196,8 @@ def audit_org(gh: Gh) -> dict:
184196
("issue_fields", f"/orgs/{ORG}/issue-fields", "Issue Fields")):
185197
got = gh.get(path, why)
186198
if got is not None:
187-
out[key] = [x["name"] for x in got]
199+
# 無効にした種別は Form から指定しても使えない
200+
out[key] = [x["name"] for x in got if x.get("is_enabled", True)]
188201
teams = gh.get(f"/orgs/{ORG}/teams", "Team 一覧")
189202
if teams is not None:
190203
for t in teams:
@@ -359,6 +372,19 @@ def add(sev, code, repo, msg):
359372
f"CLAUDE.md は hook 回帰テストを {documented} ケースと"
360373
f"書いているが、 実行すると {actual} ケース")
361374

375+
# --- Issue Form の種別 ---
376+
# **Form に存在しない種別を書いたときの挙動は公式文書に無い。** 種別の
377+
# 名前を Organization 側で変えたり無効にしたりしても、 Form は黙って
378+
# そのまま残る。 取れなかったとき(None)は「無い」と言わない。
379+
types = org.get("issue_types")
380+
if types is not None:
381+
for form, t in (local.get("form_types") or {}).items():
382+
if t and t not in types:
383+
add(WARNING, "form_type_missing", f"(org) {ORG}",
384+
f"Issue Form {form} の type: {t} が Organization の"
385+
"有効な種別に無い(その Form の Issue に種別が付かない"
386+
"可能性がある)")
387+
362388
# --- Organization ---
363389
defined = org.get("properties")
364390
if defined is not None:
@@ -377,7 +403,8 @@ def add(sev, code, repo, msg):
377403
def collect_local(repo_root: Path, repos: dict, gh: Gh) -> dict:
378404
"""手元で実行して分かること + 各 Repository の CLAUDE.md の記述。"""
379405
out = {"guard_cases": _guard_case_count(repo_root),
380-
"documented_cases": {}}
406+
"documented_cases": {},
407+
"form_types": _form_types(repo_root)}
381408
if out["guard_cases"] is None:
382409
gh.unknown.append("hook 回帰テストの実件数: "
383410
"scripts/ci/test_guard_main.sh が無い")
@@ -406,8 +433,11 @@ def render(org, repos, findings, exceptions, unknown) -> int:
406433
props = org.get("properties")
407434
print("Custom Properties: "
408435
+ ("不明" if props is None else (", ".join(props) or "なし")))
409-
print(f"Issue Types: {', '.join(org['issue_types']) or '不明'}")
410-
print(f"Issue Fields: {', '.join(org['issue_fields']) or '不明'}")
436+
for key, label in (("issue_types", "Issue Types(有効)"),
437+
("issue_fields", "Issue Fields")):
438+
got = org.get(key)
439+
print(f"{label}: "
440+
+ ("不明" if got is None else (", ".join(got) or "なし")))
411441
for slug, names in (org.get("teams") or {}).items():
412442
print(f"Team {slug}: {len(names)} Repository")
413443
print()

‎scripts/ci/test_issue_form.py‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#!/usr/bin/env python3
2+
"""Issue Form の種別(`type:`)と、 それを読む `issue-form.sh` を確かめる。
3+
4+
**ネットワークを使わない。** `gh` を差し替え、 手元の Form を正本の代わりに
5+
返す。
6+
7+
なぜ見るのか。 直近の Issue 71 件はすべて種別なしだった。 Form も `/issue`
8+
も種別を指定していなかったためで、 仕組みを足しても**値を入れる経路**が
9+
無ければ使われない。 種別を Form の `type:` 1 箇所に書き、 CLI の
10+
`/issue` もそこから読む。 どちらかが崩れると、 また種別なしの Issue に戻る。
11+
"""
12+
from __future__ import annotations
13+
14+
import os
15+
import re
16+
import subprocess
17+
import tempfile
18+
import unittest
19+
from pathlib import Path
20+
21+
ROOT = Path(__file__).resolve().parents[2]
22+
FORMS = ROOT / ".github" / "ISSUE_TEMPLATE"
23+
SCRIPT = ROOT / ".claude" / "scripts" / "issue-form.sh"
24+
25+
#: `gh api repos/<正本>/contents/<Form> --jq .content` の代わり。
26+
#: 正本の API と同じく base64 で返す。 読む場所は FAKE_FORMS。
27+
FAKE_GH = """#!/usr/bin/env bash
28+
for a in "$@"; do
29+
case "$a" in */ISSUE_TEMPLATE/*.yml) f="${a##*/}" ;; esac
30+
done
31+
base64 < "$FAKE_FORMS/$f"
32+
"""
33+
34+
35+
def _forms():
36+
"""種別を持つべき Form(`config.yml` は Form ではない)。"""
37+
return sorted(p for p in FORMS.glob("*.yml") if p.name != "config.yml")
38+
39+
40+
def _form_type(path: Path) -> "str | None":
41+
m = re.search(r"^type:\s*\"?([^\"\n]+?)\"?\s*$",
42+
path.read_text(encoding="utf-8"), re.M)
43+
return m.group(1) if m else None
44+
45+
46+
def _run(args, forms_dir=FORMS):
47+
"""`issue-form.sh` を差し替えた gh で実行する。"""
48+
with tempfile.TemporaryDirectory() as d:
49+
gh = Path(d) / "gh"
50+
gh.write_text(FAKE_GH, encoding="utf-8")
51+
gh.chmod(0o755)
52+
env = dict(os.environ, PATH=f"{d}:{os.environ['PATH']}",
53+
FAKE_FORMS=str(forms_dir))
54+
return subprocess.run(["bash", str(SCRIPT), *args], env=env,
55+
capture_output=True, text=True)
56+
57+
58+
def _filled_body(kind: str) -> str:
59+
"""skeleton から、 必須項目と選択肢を埋めた本文を作る。"""
60+
r = _run(["skeleton", kind])
61+
first = {}
62+
for ln in r.stderr.splitlines():
63+
m = re.match(r"^ (.+?): (.+)$", ln)
64+
if m:
65+
first.setdefault(m.group(1), m.group(2))
66+
out = []
67+
for ln in r.stdout.splitlines():
68+
if ln.startswith("### "):
69+
label = ln[4:]
70+
out += [ln, "", first.get(label, "x"), ""]
71+
return "\n".join(out) + "\n"
72+
73+
74+
class EveryFormHasATypeTest(unittest.TestCase):
75+
76+
def test_every_form_names_its_issue_type(self) -> None:
77+
"""**Form を足したときに `type:` を忘れると、 その Issue は種別なし。**"""
78+
self.assertTrue(_forms(), "Form が 1 件も見つかりません")
79+
for f in _forms():
80+
with self.subTest(form=f.name):
81+
self.assertTrue(_form_type(f), f"{f.name} に type: が無い")
82+
83+
84+
class TheScriptReadsTheTypeTest(unittest.TestCase):
85+
"""**種別を書き写さない。** Form の `type:` から読むこと。"""
86+
87+
def test_the_skeleton_shows_the_type(self) -> None:
88+
for f in _forms():
89+
kind, want = f.stem, _form_type(f)
90+
with self.subTest(form=f.name):
91+
r = _run(["skeleton", kind])
92+
self.assertEqual(r.returncode, 0, r.stderr)
93+
self.assertIn(f"Issue の種別: {want}(gh issue create --type"
94+
f" {want})", r.stderr)
95+
self.assertNotIn("Issue の種別", r.stdout,
96+
"種別を本文(標準出力)に混ぜています")
97+
98+
def test_the_check_names_the_type_right_before_creation(self) -> None:
99+
"""check は作成の直前に実行する。 そこで `--type` を思い出させる。"""
100+
for f in _forms():
101+
kind, want = f.stem, _form_type(f)
102+
with self.subTest(form=f.name), tempfile.NamedTemporaryFile(
103+
"w", suffix=".md", encoding="utf-8", delete=False) as b:
104+
b.write(_filled_body(kind))
105+
try:
106+
r = _run(["check", kind, b.name])
107+
self.assertEqual(r.returncode, 0, r.stderr)
108+
self.assertIn("OK:", r.stdout)
109+
self.assertIn(f"--type {want}", r.stdout)
110+
finally:
111+
os.unlink(b.name)
112+
113+
def test_a_form_without_a_type_says_so(self) -> None:
114+
"""**種別が無いのに空の `--type` を案内しないこと。**"""
115+
with tempfile.TemporaryDirectory() as d:
116+
src = (FORMS / "feature.yml").read_text(encoding="utf-8")
117+
(Path(d) / "feature.yml").write_text(
118+
re.sub(r"^type:.*\n", "", src, flags=re.M), encoding="utf-8")
119+
r = _run(["skeleton", "feature"], forms_dir=Path(d))
120+
self.assertEqual(r.returncode, 0, r.stderr)
121+
self.assertIn("Issue の種別: なし", r.stderr)
122+
self.assertNotIn("--type (", r.stderr)
123+
124+
125+
if __name__ == "__main__":
126+
unittest.main()

‎scripts/ci/test_org_audit.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,47 @@ def test_a_private_repo_may_hold_customer_data(self) -> None:
400400
self.assertEqual(self._find(True, "customer-confidential"), [])
401401

402402

403+
class ItKeepsTheFormTypesUsableTest(unittest.TestCase):
404+
"""**Form の `type:` が Organization に無いと、 黙って種別なしに戻る。**
405+
406+
Form に存在しない種別を書いたときの挙動は公式文書に無い。 種別の名前を
407+
Organization 側で変えたり無効にしたりしても、 Form はそのまま残る。
408+
"""
409+
410+
def test_a_type_missing_in_the_org_is_found(self) -> None:
411+
local = {"form_types": {"experiment.yml": "Experiment",
412+
"feature.yml": "Feature"}}
413+
findings, _ = A.classify(
414+
_org(issue_types=["Task", "Bug", "Feature"]), {}, local)
415+
self.assertEqual(_codes(findings), ["form_type_missing"])
416+
self.assertIn("experiment.yml", findings[0]["message"])
417+
418+
def test_nothing_is_claimed_when_the_types_are_unknown(self) -> None:
419+
"""**取れないことを「無い」と言わない。**"""
420+
local = {"form_types": {"experiment.yml": "Experiment"}}
421+
findings, _ = A.classify(_org(issue_types=None), {}, local)
422+
self.assertEqual(findings, [])
423+
424+
def test_a_disabled_type_is_not_usable(self) -> None:
425+
"""無効にした種別は、 一覧にあっても Form から使えない。"""
426+
gh = A.Gh()
427+
428+
def fake(path, why):
429+
if path.endswith("/issue-types"):
430+
return [{"name": "Feature", "is_enabled": True},
431+
{"name": "Experiment", "is_enabled": False}]
432+
return {} if path == f"/orgs/{A.ORG}" else []
433+
gh.get = fake
434+
self.assertEqual(A.audit_org(gh)["issue_types"], ["Feature"])
435+
436+
def test_the_types_are_read_from_the_real_forms(self) -> None:
437+
got = A._form_types(HERE.parents[1])
438+
self.assertLessEqual({"bug.yml", "experiment.yml", "feature.yml"},
439+
set(got))
440+
self.assertNotIn("config.yml", got, "config.yml は Form ではない")
441+
self.assertEqual(got["feature.yml"], "Feature")
442+
443+
403444
class ItComparesTheDocumentWithTheImplementationTest(unittest.TestCase):
404445
"""**文章に書いた数字は、 実装が増えても変わらない。**
405446

0 commit comments

Comments
 (0)