Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2

[*.py]
indent_size = 4

[*.md]
# Markdown は行末2スペースが改行の意味を持つため、末尾空白を削らない
trim_trailing_whitespace = false

[Makefile]
indent_style = tab
22 changes: 22 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# 改行コードを LF に固定し、CRLF 混入の再発を防ぐ(ADR-005)
* text=auto eol=lf

*.py text eol=lf
*.md text eol=lf
*.json text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.toml text eol=lf
*.cfg text eol=lf
*.ini text eol=lf
*.sh text eol=lf
.gitignore text eol=lf
.gitattributes text eol=lf
.editorconfig text eol=lf

# バイナリ(差分・改行変換の対象外)
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.pdf binary
4 changes: 2 additions & 2 deletions .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Dependency Review
uses: actions/dependency-review-action@v4
uses: actions/dependency-review-action@v5
with:
fail-on-severity: high
4 changes: 2 additions & 2 deletions .github/workflows/secrets-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Run gitleaks
uses: gitleaks/gitleaks-action@v2
uses: gitleaks/gitleaks-action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
72 changes: 1 addition & 71 deletions .github/workflows/weekly-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,77 +36,7 @@ jobs:
- name: Score and rank issues
id: scoring
run: |
python3 - <<'PYEOF'
import json, datetime

with open('/tmp/issues.json') as f:
issues = json.load(f)

now = datetime.datetime.now(datetime.timezone.utc)

def impact(labels):
names = [l['name'].lower() for l in labels]
if any('bug' in n or 'crash' in n or 'security' in n for n in names): return 5
if any('enhancement' in n or 'feature' in n for n in names): return 3
if any('docs' in n or 'refactor' in n for n in names): return 1
return 2

def effort(body):
if not body: return 3
l = len(body)
if l < 200: return 1
if l < 600: return 2
if l < 1500: return 3
return 4

def thumbs_up(reaction_groups):
# The rubric counts 👍 only, not every reaction type.
for rg in reaction_groups:
if rg.get('content') == 'THUMBS_UP':
return rg.get('users', {}).get('totalCount', 0)
return 0

# Urgency mirrors issue-triage/references/scoring-rubric.md (max +7).
def urgency_bonus(issue, age, comments):
names = [l['name'].lower() for l in issue.get('labels', [])]
bonus = 0
if age > 30 and comments >= 3:
bonus += 2
if issue.get('milestone'):
bonus += 1
if thumbs_up(issue.get('reactionGroups', [])) >= 5:
bonus += 1
if any(n in ('urgent', 'hotfix', 'p0') for n in names):
bonus += 3
return bonus

scored = []
for i in issues:
imp = impact(i.get('labels', []))
eff = effort(i.get('body', ''))
age = (now - datetime.datetime.fromisoformat(i['createdAt'].replace('Z','+00:00'))).days
comments = i.get('comments', 0)
urgency = urgency_bonus(i, age, comments)
score = imp * (6 - eff) + urgency
scored.append({**i, 'score': score, 'impact': imp, 'effort': eff})

scored.sort(key=lambda x: -x['score'])
top10 = scored[:10]

lines = ['## 🔢 Top Issues This Week\n']
lines.append('| # | Title | Score |')
lines.append('|---|---|---|')
for i in top10:
lines.append(f"| #{i['number']} | {i['title'][:60]} | {i['score']} |")

if scored[10:]:
lines.append(f'\n_Plus {len(scored) - 10} more issues in backlog._')

with open('/tmp/triage_output.md', 'w') as f:
f.write('\n'.join(lines))

print('\n'.join(lines))
PYEOF
python3 scripts/triage_scoring.py /tmp/issues.json

- name: Generate AI summary and publish report
env:
Expand Down
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ Claude Code loads `references/` as context automatically.

---

## Running Tests / Previewing the Promo Articles

Run the Python unit tests before opening a PR:

```bash
npm test
```

`zenn-cli` is kept as a devDependency because this repository owns local `articles/` drafts and the `books/claude-code-skill-pack` content. Use `npm run preview` to check those promo articles locally, and keep `npm run new:article` available for new Zenn drafts.

---

## Improve references/ (Most Valuable Contribution)

Found a comment pattern that was misclassified? Open an Issue with label `references-improvement`:
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
{
"name": "github-flow-kit",
"version": "0.3.0",
"private": true,
"description": "Claude Code skill pack that automates GitHub workflows: PR review responses, release notes, issue triage, repo onboarding, and security audits.",
"scripts": {
"test": "python3 -m unittest discover -s tests -p 'test_*.py'",
"new:article": "zenn new:article",
"preview": "zenn preview"
},
Expand Down
142 changes: 142 additions & 0 deletions scripts/triage_scoring.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Shared issue-triage scoring logic.

The weekly workflow and rubric golden tests both import this module so the
formula has one executable source of truth.
"""
from __future__ import annotations

import datetime as dt
import json
import sys
from pathlib import Path
from typing import Any, Iterable


def _label_names(labels: Iterable[dict[str, Any]] | None) -> list[str]:
return [
str(label.get("name", "")).lower()
for label in (labels or [])
if isinstance(label, dict)
]


def impact(labels: Iterable[dict[str, Any]] | None) -> int:
names = _label_names(labels)
if any("bug" in name or "crash" in name or "security" in name for name in names):
return 5
if any("enhancement" in name or "feature" in name for name in names):
return 3
if any("docs" in name or "refactor" in name for name in names):
return 1
return 2


def effort(body: str | None) -> int:
if not body:
return 3
length = len(body)
if length < 200:
return 1
if length < 600:
return 2
if length < 1500:
return 3
return 4


def thumbs_up(reaction_groups: Iterable[dict[str, Any]] | None) -> int:
"""Return only the thumbs-up reaction count used by the rubric."""
for reaction_group in reaction_groups or []:
if reaction_group.get("content") == "THUMBS_UP":
users = reaction_group.get("users") or {}
return int(users.get("totalCount") or 0)
return 0


def urgency_bonus(issue: dict[str, Any], age: int, comments: int) -> int:
names = _label_names(issue.get("labels", []))
bonus = 0
if age > 30 and comments >= 3:
bonus += 2
if issue.get("milestone"):
bonus += 1
if thumbs_up(issue.get("reactionGroups", [])) >= 5:
bonus += 1
if any(name in ("urgent", "hotfix", "p0") for name in names):
bonus += 3
return bonus


def score(impact_value: int, effort_value: int, urgency: int) -> int:
return impact_value * (6 - effort_value) + urgency


def band(score_value: int) -> str:
if score_value >= 15:
return "🔴 Sprint必須"
if score_value >= 10:
return "🟡 Sprint推奨"
if score_value >= 5:
return "🟢 Backlog"
return "⚪ 低優先度"


def _parse_created_at(value: str) -> dt.datetime:
return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))


def rank_issues(
issues: Iterable[dict[str, Any]],
now: dt.datetime | None = None,
) -> list[dict[str, Any]]:
now = now or dt.datetime.now(dt.timezone.utc)
scored = []

for issue in issues:
impact_value = impact(issue.get("labels", []))
effort_value = effort(issue.get("body", ""))
age = (now - _parse_created_at(issue["createdAt"])).days
comments = int(issue.get("comments", 0) or 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle gh comment arrays before scoring

When the weekly workflow fetches any issue with at least one comment, gh issue list --json comments supplies comments as a list of comment objects, so int([...]) raises TypeError before rendering the summary. The previous inline code only compared this value after the age check; this cast makes even a freshly-created commented issue abort the run, so use len(comments) or totalCount instead of int-casting the raw field.

Useful? React with 👍 / 👎.

urgency = urgency_bonus(issue, age, comments)
total = score(impact_value, effort_value, urgency)
scored.append(
{**issue, "score": total, "impact": impact_value, "effort": effort_value}
)

return sorted(scored, key=lambda issue: -issue["score"])


def render_top_issues(scored: list[dict[str, Any]]) -> str:
top10 = scored[:10]
lines = ["## 🔢 Top Issues This Week", "", "| # | Title | Score |", "|---|---|---|"]

for issue in top10:
lines.append(f"| #{issue['number']} | {issue['title'][:60]} | {issue['score']} |")

if scored[10:]:
lines.append("")
lines.append(f"_Plus {len(scored) - 10} more issues in backlog._")

return "\n".join(lines)


def main(argv: list[str]) -> int:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")

if len(argv) not in (2, 3):
print("Usage: triage_scoring.py ISSUES_JSON [OUTPUT_MD]", file=sys.stderr)
return 2

issues_path = Path(argv[1])
output_path = Path(argv[2]) if len(argv) == 3 else Path("/tmp/triage_output.md")
issues = json.loads(issues_path.read_text(encoding="utf-8"))
output = render_top_issues(rank_issues(issues))
output_path.write_text(output, encoding="utf-8")
print(output)
return 0


if __name__ == "__main__":
raise SystemExit(main(sys.argv))
39 changes: 39 additions & 0 deletions tests/test_changelog_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Guard package.json version and CHANGELOG.md release headings."""
import json
import re
import unittest
from pathlib import Path

REPO = Path(__file__).resolve().parents[1]
PACKAGE_JSON = REPO / "package.json"
CHANGELOG = REPO / "CHANGELOG.md"
Comment on lines +9 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add package/changelog paths to the guard workflow

This guard reads package.json and CHANGELOG.md, but I checked .github/workflows/skill-validate.yml and its PR/push paths filter only includes SKILL/reference/test/secret/workflow files, not either of these inputs. A PR that only bumps the package version or edits the changelog will therefore skip the unittest job, so the new sync test will not protect the drift it is meant to catch; add these files to the validation trigger or remove the path filter.

Useful? React with 👍 / 👎.



def package_version():
return json.loads(PACKAGE_JSON.read_text(encoding="utf-8"))["version"]


class TestChangelogSync(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.version = package_version()
cls.changelog = CHANGELOG.read_text(encoding="utf-8")

def test_unreleased_section_exists(self):
self.assertRegex(
self.changelog,
r"(?m)^## \[Unreleased\](?:\s|$)",
"CHANGELOG.md must keep an [Unreleased] section",
)

def test_package_version_has_changelog_heading(self):
self.assertRegex(
self.changelog,
rf"(?m)^## \[{re.escape(self.version)}\](?:\s|$)",
f"CHANGELOG.md must include a heading for package.json version {self.version}",
)


if __name__ == "__main__":
unittest.main(verbosity=2)
18 changes: 2 additions & 16 deletions tests/test_scoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,13 @@
import unittest
from pathlib import Path

from scripts.triage_scoring import band, score

REPO = Path(__file__).resolve().parents[1]
RUBRIC = REPO / "issue-triage" / "references" / "scoring-rubric.md"
SKILL = REPO / "issue-triage" / "SKILL.md"


# score = impact * (6 - effort) + urgency (see issue-triage/SKILL.md)
def score(impact: int, effort: int, urgency: int) -> int:
return impact * (6 - effort) + urgency


# Display bands (issue-triage/SKILL.md Step 3 + scoring-rubric.md)
def band(s: int) -> str:
if s >= 15:
return "🔴 Sprint必須"
if s >= 10:
return "🟡 Sprint推奨"
if s >= 5:
return "🟢 Backlog"
return "⚪ 低優先度"


def parse_examples(text: str):
"""Yield (impact, effort, urgency, claimed_score, band) from the example table."""
section = text.split("## スコアリング例", 1)
Expand Down