📎 Update the update-changelog skill and gh.py tool

This commit is contained in:
Andrey Antukh 2026-06-02 10:09:53 +02:00
parent 3a4e3aaeac
commit 0d2e0f8367
2 changed files with 227 additions and 32 deletions

View File

@ -84,6 +84,22 @@ The `prs` command returns JSON with `number`, `title`, `body`, `state`,
`merged_at`, `author`, `labels`, and `closing_issues`. PRs are fetched in
batches of 50 via GraphQL to stay within API limits.
You can also list all PRs in a milestone in a single call:
```bash
# All merged PRs in a milestone (default)
python3 tools/gh.py prs --milestone "2.16.0"
# All states (merged, open, closed)
python3 tools/gh.py prs --milestone "2.16.0" --state all
# Open PRs only
python3 tools/gh.py prs --milestone "2.16.0" --state open
```
The milestone path uses paginated GraphQL on the milestone's `pullRequests`
connection (100 per page), avoiding one-by-one fetches.
### 5. Categorize entries — strictly by issue type, never by labels or emoji
Use the **Issue Type** field (GitHub's native issue type, exposed as
@ -209,6 +225,72 @@ Read the top of `CHANGES.md` and confirm:
- <fix description> (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
```
### 10. Cross-reference milestone PRs against the changelog
Issues can be fixed by PRs that aren't in the milestone, and merged PRs in
the milestone may not close any tracked issue. After writing, run a full
cross-reference to catch gaps:
```bash
# List all merged PRs in the milestone
python3 tools/gh.py prs --milestone "<MILESTONE>" --state merged > /tmp/milestone-prs.json
# Extract PR numbers from the changelog section
python3 -c "
import json, re
with open('CHANGES.md') as f:
content = f.read()
# Extract the version section (adjust regex to match the actual version)
match = re.search(r'## <MILESTONE> \(Unreleased\)\n(.*?)(?:\n## |\Z)', content, re.DOTALL)
section = match.group(1)
# Collect all PR numbers referenced
changelog_prs = set()
for m in re.findall(r'\[#(\d+)\]\(https://github\.com/penpot/penpot/pull/\d+\)', section):
changelog_prs.add(int(m))
# Collect all milestone PRs (filtered)
with open('/tmp/milestone-prs.json') as f:
milestone_prs = json.load(f)
milestone_merged = {pr['number'] for pr in milestone_prs}
# PRs in milestone but not in changelog
missing = sorted(milestone_merged - changelog_prs)
print(f'Milestone merged PRs: {len(milestone_merged)}')
print(f'Changelog referenced PRs: {len(changelog_prs)}')
print(f'PRs in milestone but NOT in changelog: {len(missing)}')
for num in missing:
pr = next(p for p in milestone_prs if p['number'] == num)
print(f' #{num} {pr[\"title\"][:80]}')
"
```
For each missing PR found, decide whether it should be added to the
changelog or is legitimately excluded (check its labels).
Also verify that no closed-unmerged PRs remain in the changelog:
```bash
python3 tools/gh.py prs --milestone "<MILESTONE>" --state all | python3 -c "
import json, sys
data = json.load(sys.stdin)
closed = [p for p in data if p['state'] == 'CLOSED']
if closed:
print('WARNING: CLOSED (unmerged) PRs in milestone:')
for p in closed:
print(f' #{p[\"number\"]} {p[\"title\"][:80]}')
"
```
**Post-edit audit checklist:**
- ✅ All referenced PRs are merged (no closed-unmerged artifacts)
- ✅ Every merged milestone PR is either in the changelog or excluded by label
- ✅ PR and issue counts are internally consistent
- ✅ No false-positive PR-to-issue associations
## Key Principles
- **Issue = changelog unit.** The primary link always points to the

View File

@ -6,7 +6,7 @@ Uses GitHub GraphQL and REST APIs via the authenticated ``gh`` CLI.
Subcommands:
issues List issues in a milestone
prs Fetch details for one or more PRs
prs Fetch details for one or more PRs (by number or milestone)
Usage:
python3 tools/gh.py issues <milestone-title> (default: state=closed)
@ -16,6 +16,8 @@ Usage:
python3 tools/gh.py prs 9179 9204 9311
python3 tools/gh.py prs --file prs.txt
cat prs.txt | python3 tools/gh.py prs --stdin
python3 tools/gh.py prs --milestone "2.16.0" (default: state=merged)
python3 tools/gh.py prs --milestone "2.16.0" --state all
Prerequisites:
- gh CLI authenticated (gh auth status)
@ -40,19 +42,6 @@ REPO_NAME = "penpot"
# ─────────────────────────────────────────────
def run_gh(method: str, endpoint: str, **kwargs: Any) -> Any:
"""Run a ``gh api`` call and return parsed JSON."""
cmd = ["gh", "api", endpoint, "--method", method]
for key, val in kwargs.items():
if val is not None:
cmd.extend(["-f", f"{key}={val}"])
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"gh error: {result.stderr}", file=sys.stderr)
sys.exit(1)
return json.loads(result.stdout)
def run_gh_graphql(query: str, variables: dict) -> Any:
"""Run a GraphQL query via ``gh api graphql --input -``."""
payload = json.dumps({"query": query, "variables": variables})
@ -69,6 +58,44 @@ def run_gh_graphql(query: str, variables: dict) -> Any:
return body["data"]
# ─────────────────────────────────────────────
# Shared: milestone lookup
# ─────────────────────────────────────────────
GQL_FIND_MILESTONE_QUERY = """\
query($owner: String!, $repo: String!, $title: String!) {
repository(owner: $owner, name: $repo) {
milestones(query: $title, first: 20, states: [OPEN CLOSED]) {
nodes {
number
title
state
issues(states: [OPEN]) { totalCount }
closed_issues: issues(states: [CLOSED]) { totalCount }
}
}
}
}
"""
def find_milestone(title: str) -> dict:
"""Look up milestone by title via GraphQL, return {number, title, open_issues, closed_issues}."""
variables = {"owner": OWNER, "repo": REPO_NAME, "title": title}
data = run_gh_graphql(GQL_FIND_MILESTONE_QUERY, variables)
nodes = data["repository"]["milestones"]["nodes"]
for ms in nodes:
if ms["title"] == title:
return {
"number": ms["number"],
"title": ms["title"],
"open_issues": ms["issues"]["totalCount"],
"closed_issues": ms["closed_issues"]["totalCount"],
}
print(f"ERROR: Milestone \"{title}\" not found in {REPO}", file=sys.stderr)
sys.exit(1)
# ─────────────────────────────────────────────
# Subcommand: issues
# ─────────────────────────────────────────────
@ -97,21 +124,6 @@ query($owner: String!, $repo: String!, $milestone: Int!, $cursor: String) {
"""
def find_milestone(title: str) -> dict:
"""Look up milestone by title, return {number, title, open_issues, closed_issues}."""
data = run_gh("GET", f"repos/{OWNER}/{REPO_NAME}/milestones?per_page=100&state=all")
for ms in data:
if ms["title"] == title:
return {
"number": ms["number"],
"title": ms["title"],
"open_issues": ms["open_issues"],
"closed_issues": ms["closed_issues"],
}
print(f"ERROR: Milestone \"{title}\" not found in {REPO}", file=sys.stderr)
sys.exit(1)
def fetch_milestone_issues(milestone_num: int, states: str) -> list[dict]:
"""
Fetch all issues in a milestone via paginated GraphQL.
@ -277,10 +289,103 @@ def fetch_prs_batch(pr_numbers: list[int]) -> list[dict]:
return results
GQL_MILESTONE_PRS_QUERY = """\
query($owner: String!, $repo: String!, $milestone: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
milestone(number: $milestone) {
pullRequests(first: 100, after: $cursor, states: __STATES__) {
totalCount
pageInfo { hasNextPage endCursor }
nodes {
... on PullRequest {
number
title
body
state
mergedAt
createdAt
author { login }
labels(first: 20) { nodes { name } }
closingIssuesReferences(first: 5) { nodes { number } }
}
}
}
}
}
}
"""
def fetch_milestone_prs(milestone_num: int, states: str) -> list[dict]:
"""
Fetch all pull requests in a milestone via paginated GraphQL.
Args:
milestone_num: milestone number
states: GraphQL states enum array literal, e.g. ``"[MERGED]"`` or ``"[OPEN CLOSED MERGED]"``
Returns:
List of {number, title, body, state, merged_at, created_at, author,
labels: [str], closing_issues: [int]}
"""
query = GQL_MILESTONE_PRS_QUERY.replace("__STATES__", states)
all_nodes: list[dict] = []
cursor: str | None = None
while True:
variables: dict[str, Any] = {
"owner": OWNER,
"repo": REPO_NAME,
"milestone": milestone_num,
"cursor": cursor,
}
data = run_gh_graphql(query, variables)
prs = data["repository"]["milestone"]["pullRequests"]
page_info = prs["pageInfo"]
for node in prs["nodes"]:
if node is None:
continue
all_nodes.append({
"number": node["number"],
"title": node["title"],
"body": node.get("body"),
"state": node["state"],
"merged_at": node.get("mergedAt"),
"created_at": node.get("createdAt"),
"author": node["author"]["login"] if node["author"] else None,
"labels": [lbl["name"] for lbl in node["labels"]["nodes"]],
"closing_issues": [iss["number"] for iss in node["closingIssuesReferences"]["nodes"]],
})
total = len(all_nodes)
print(f" ... fetched {total} PRs so far", file=sys.stderr)
if not page_info["hasNextPage"]:
break
cursor = page_info["endCursor"]
return all_nodes
def cmd_prs(args: argparse.Namespace) -> None:
"""Handle the ``prs`` subcommand."""
# Collect PR numbers from args / file / stdin
# ── Milestone path ──────────────────────────────────────────────
if args.milestone:
print(f"Looking up milestone \"{args.milestone}\"...", file=sys.stderr)
ms = find_milestone(args.milestone)
state_map = {"open": "[OPEN]", "closed": "[CLOSED]", "merged": "[MERGED]", "all": "[OPEN CLOSED MERGED]"}
gql_states = state_map[args.state]
print(f"Fetching {args.state} PRs via GraphQL...", file=sys.stderr)
prs = fetch_milestone_prs(ms["number"], gql_states)
print(f"Fetched {len(prs)} PRs total", file=sys.stderr)
print(json.dumps(prs, indent=2))
return
# ── Number-based path ───────────────────────────────────────────
pr_numbers: list[int] = []
if args.numbers:
@ -300,7 +405,7 @@ def cmd_prs(args: argparse.Namespace) -> None:
pr_numbers.append(int(line))
if not pr_numbers:
print("ERROR: no PR numbers provided (pass numbers, --file, or --stdin)",
print("ERROR: no PR numbers provided (pass numbers, --file, --stdin, or --milestone)",
file=sys.stderr)
sys.exit(1)
@ -350,7 +455,7 @@ def main() -> None:
p_issues.set_defaults(func=cmd_issues)
# --- prs ---
p_prs = sub.add_parser("prs", help="Fetch details for one or more PRs")
p_prs = sub.add_parser("prs", help="Fetch details for one or more PRs (by number or milestone)")
p_prs.add_argument(
"numbers", type=int, nargs="*",
help="PR numbers to fetch (space-separated)"
@ -363,6 +468,14 @@ def main() -> None:
"--stdin", action="store_true",
help="Read PR numbers from stdin (one per line)"
)
p_prs.add_argument(
"--milestone", type=str,
help="Milestone title, e.g. '2.16.0' (fetches all PRs in the milestone)"
)
p_prs.add_argument(
"--state", choices=["open", "closed", "merged", "all"], default="merged",
help="PR state filter when using --milestone (default: merged)"
)
p_prs.set_defaults(func=cmd_prs)
args = parser.parse_args()