Add minor usability improvements to update-changelog skill

This commit is contained in:
Andrey Antukh 2026-05-27 17:44:50 +02:00
parent 50ec6ad777
commit bee1a89698
2 changed files with 214 additions and 46 deletions

View File

@ -36,13 +36,13 @@ Use the helper script. It uses GraphQL for efficient single-pass fetching
```bash ```bash
# All closed issues (default) # All closed issues (default)
python3 tools/gh.py issues "2.16.0" python3 tools/gh.py issues --milestone "2.16.0"
# Include open issues too # Include open issues too
python3 tools/gh.py issues "2.16.0" --state all python3 tools/gh.py issues --milestone "2.16.0" --state all
# Exclude entries that should not go in the changelog # Exclude entries that should not go in the changelog
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" python3 tools/gh.py issues --milestone "2.16.0" --exclude "release blocker,no changelog"
``` ```
**Exclusion rules (issue-level):** **Exclusion rules (issue-level):**
@ -65,7 +65,7 @@ If updating from an existing `CHANGES.md`, find issues in the milestone that
are NOT yet referenced in the changelog: are NOT yet referenced in the changelog:
```bash ```bash
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" --compare CHANGES.md python3 tools/gh.py issues --milestone "2.16.0" --exclude "release blocker,no changelog" --compare CHANGES.md
``` ```
This returns a filtered JSON array with only the missing issues. This returns a filtered JSON array with only the missing issues.
@ -102,11 +102,50 @@ python3 tools/gh.py prs --milestone "2.16.0" --state all
``` ```
The `prs` command returns JSON with `number`, `title`, `body`, `state`, The `prs` command returns JSON with `number`, `title`, `body`, `state`,
`merged_at`, `author`, `labels`, and `closing_issues`. PRs are fetched in `merged_at`, `milestone`, `author`, `labels`, and `closing_issues`. PRs are
batches of 50 via GraphQL to stay within API limits (milestone mode uses fetched in batches of 50 via GraphQL to stay within API limits (milestone mode
paginated GraphQL on the milestone's `pullRequests` connection). uses paginated GraphQL on the milestone's `pullRequests` connection).
### 5. Categorize entries — strictly by issue type, never by labels or emoji > **⚠️ CRITICAL: Never iterate PRs one-by-one with `for pr in ...; do gh pr view ...; done`.**
> This causes N+1 API calls and will quickly exhaust GitHub's rate limit.
> **Always use `tools/gh.py prs <N1> <N2> ...`** which batches up to 50 PRs
> per GraphQL query. If a field you need is missing from `gh.py`'s output,
> **add it to the script** (edit the `GQL_PRS_QUERY_ITEM` template and the
> result builder in `fetch_prs_batch`) rather than working around it with
> one-by-one calls.
### 5. Fetch additional issue details (batch by number)
When you need to look up specific issues (e.g. to check the `issue_type`
field for categorization, or to find closing PRs for a particular issue),
use the same batch-by-number pattern as PRs:
```bash
# One or more issue numbers
python3 tools/gh.py issues 9010 9899 9900
# From a file
python3 tools/gh.py issues --file issues.txt
# From stdin
cat issues.txt | python3 tools/gh.py issues --stdin
```
This returns JSON with `number`, `title`, `state`, `issue_type`, `labels`,
and `closing_prs` for each issue. The same batching rules apply (50 issues
per GraphQL query).
For milestone-wide listing, use the `--milestone` flag:
```bash
# All closed issues (default)
python3 tools/gh.py issues --milestone "2.17.0"
# With exclusions and comparison
python3 tools/gh.py issues --milestone "2.17.0" --exclude "release blocker,no changelog" --compare CHANGES.md
```
### 6. Categorize entries — strictly by issue type, never by labels or emoji
Use the **Issue Type** field (GitHub's native issue type, exposed as Use the **Issue Type** field (GitHub's native issue type, exposed as
`issue_type` in the `gh.py` JSON output) to determine which section an entry `issue_type` in the `gh.py` JSON output) to determine which section an entry
@ -163,7 +202,7 @@ tracked in the milestone.
> skip it. PR [#3](https://github.com/penpot/penpot/pull/3) (ancient License PR > skip it. PR [#3](https://github.com/penpot/penpot/pull/3) (ancient License PR
> claiming to close a plugin API issue) is a known example. > claiming to close a plugin API issue) is a known example.
### 5a. ⚠️ Verify PR merge status before writing ### 7. ⚠️ Verify PR merge status before writing
A closed issue may list closing PRs that were **closed without merging** A closed issue may list closing PRs that were **closed without merging**
(e.g., a community PR that was superseded by another). The changelog must (e.g., a community PR that was superseded by another). The changelog must
@ -187,7 +226,7 @@ superseded it:
Replace the reference in the changelog entry with the correct merged PR number. Replace the reference in the changelog entry with the correct merged PR number.
### 6. Read the current CHANGES.md ### 8. Read the current CHANGES.md
Read the top of `CHANGES.md` to understand the existing format and find the Read the top of `CHANGES.md` to understand the existing format and find the
insertion point (newest version goes at the top, after the `# CHANGELOG` insertion point (newest version goes at the top, after the `# CHANGELOG`
@ -222,7 +261,7 @@ Format details:
- When an entry already exists in an earlier version section, it must be removed - When an entry already exists in an earlier version section, it must be removed
from the current version to avoid duplicates from the current version to avoid duplicates
### 7. Build the description text ### 9. Build the description text
Derive the description from the issue title, not the PR title. Strip leading Derive the description from the issue title, not the PR title. Strip leading
emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`) and focus on the emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`) and focus on the
@ -236,13 +275,13 @@ Examples:
| `Comment content is not sanitized before rendering, enabling stored XSS` | `Sanitize comment content on rendering` | | `Comment content is not sanitized before rendering, enabling stored XSS` | `Sanitize comment content on rendering` |
| `Custom uploaded font family names are not sanitized` | `Sanitize font family names on custom uploaded fonts` | | `Custom uploaded font family names are not sanitized` | `Sanitize font family names on custom uploaded fonts` |
### 8. Insert the section into CHANGES.md ### 10. Insert the section into CHANGES.md
Insert the new version section right after the `# CHANGELOG` header (before Insert the new version section right after the `# CHANGELOG` header (before
the previous version entry). Use the `edit` tool with enough context to make the previous version entry). Use the `edit` tool with enough context to make
a unique match. a unique match.
### 9. Verify ### 11. Verify
Read the top of `CHANGES.md` and confirm: Read the top of `CHANGES.md` and confirm:
- The version header is correct - The version header is correct
@ -251,7 +290,7 @@ Read the top of `CHANGES.md` and confirm:
- The section ordering is correct (newest first) - The section ordering is correct (newest first)
- Formatting matches the surrounding entries - Formatting matches the surrounding entries
### 10. Cross-reference milestone PRs against the changelog ### 12. Cross-reference milestone PRs against the changelog
Issues can be fixed by PRs that aren't in the milestone, and merged PRs in 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 the milestone may not close any tracked issue. After writing, run a full

View File

@ -174,41 +174,144 @@ def load_existing_issue_numbers(filepath: str) -> set[int]:
return nums return nums
# ─────────────────────────────────────────────
# Subcommand: issues — batch fetch by number
# ─────────────────────────────────────────────
ISSUES_BATCH_SIZE = 50
GQL_ISSUES_QUERY_ITEM = """\
issue_{num}: issue(number: {num}) {{
number
title
state
issueType {{ name }}
labels(first: 20) {{ nodes {{ name }} }}
closedByPullRequestsReferences(first: 5) {{ nodes {{ number }} }}
}}
"""
GQL_ISSUES_QUERY_WRAPPER = """\
query($owner: String!, $repo: String!) {{
repository(owner: $owner, name: $repo) {{
{items}
}}
}}
"""
def fetch_issues_batch(issue_numbers: list[int]) -> list[dict]:
"""
Fetch details for a list of issue numbers in a single GraphQL query.
Uses numbered aliases (issue_1234, issue_5678, ) so each issue is looked
up by number in one round-trip.
"""
items = "\n".join(
GQL_ISSUES_QUERY_ITEM.format(num=n) for n in issue_numbers
)
query = GQL_ISSUES_QUERY_WRAPPER.format(items=items)
variables = {"owner": OWNER, "repo": REPO_NAME}
data = run_gh_graphql(query, variables)
repo = data["repository"]
results: list[dict] = []
for num in issue_numbers:
issue = repo.get(f"issue_{num}")
if issue is None:
results.append({
"number": num,
"error": "not_found",
})
continue
issue_type = issue.get("issueType")
results.append({
"number": issue["number"],
"title": issue["title"],
"state": issue["state"],
"issue_type": issue_type["name"] if issue_type else None,
"labels": [lbl["name"] for lbl in issue["labels"]["nodes"]],
"closing_prs": [pr["number"] for pr in issue["closedByPullRequestsReferences"]["nodes"]],
})
return results
def cmd_issues(args: argparse.Namespace) -> None: def cmd_issues(args: argparse.Namespace) -> None:
"""Handle the ``issues`` subcommand.""" """Handle the ``issues`` subcommand."""
# Resolve milestone # ── Milestone mode: fetch all issues in a milestone ──
print(f"Looking up milestone \"{args.milestone}\"...", file=sys.stderr) if args.milestone:
ms = find_milestone(args.milestone) print(f"Looking up milestone \"{args.milestone}\"...", file=sys.stderr)
print(f"Milestone #{ms['number']}: {ms['open_issues']} open, {ms['closed_issues']} closed", ms = find_milestone(args.milestone)
print(f"Milestone #{ms['number']}: {ms['open_issues']} open, {ms['closed_issues']} closed",
file=sys.stderr)
state_map = {"open": "[OPEN]", "closed": "[CLOSED]", "all": "[OPEN CLOSED]"}
gql_states = state_map[args.state]
print(f"Fetching {args.state} issues via GraphQL...", file=sys.stderr)
issues = fetch_milestone_issues(ms["number"], gql_states)
print(f"Fetched {len(issues)} issues total", file=sys.stderr)
# Filter by excluded labels
if args.exclude:
exclusions = set(label.strip() for label in args.exclude.split(","))
filtered = [issue for issue in issues
if not any(lbl in exclusions for lbl in issue["labels"])]
print(f"After excluding labels: {len(filtered)} issues", file=sys.stderr)
issues = filtered
# Filter to issues NOT yet in the comparison file (if --compare given)
if args.compare:
existing_nums = load_existing_issue_numbers(args.compare)
missing = [iss for iss in issues if iss["number"] not in existing_nums]
missing.sort(key=lambda x: x["number"])
print(f"Issues not yet in changelog: {len(missing)}", file=sys.stderr)
issues = missing
print(json.dumps(issues, indent=2))
return
# ── Batch mode: fetch specific issues by number ──
issue_numbers: list[int] = []
if args.numbers:
issue_numbers.extend(args.numbers)
if args.file:
with open(args.file) as f:
for line in f:
line = line.strip()
if line:
issue_numbers.append(int(line))
if args.stdin:
for line in sys.stdin:
line = line.strip()
if line:
issue_numbers.append(int(line))
if not issue_numbers:
print("ERROR: no issue numbers provided (pass numbers, --file, --stdin, or --milestone)",
file=sys.stderr)
sys.exit(1)
# Deduplicate while preserving order
seen: set[int] = set()
issue_numbers = [n for n in issue_numbers if not (n in seen or seen.add(n))]
print(f"Fetching {len(issue_numbers)} issues in batches of {ISSUES_BATCH_SIZE}...",
file=sys.stderr) file=sys.stderr)
# Map state to GraphQL enum array literal all_results: list[dict] = []
state_map = {"open": "[OPEN]", "closed": "[CLOSED]", "all": "[OPEN CLOSED]"} for i in range(0, len(issue_numbers), ISSUES_BATCH_SIZE):
gql_states = state_map[args.state] batch = issue_numbers[i : i + ISSUES_BATCH_SIZE]
print(f" batch {i // ISSUES_BATCH_SIZE + 1}: issues {batch[0]}..{batch[-1]}",
file=sys.stderr)
all_results.extend(fetch_issues_batch(batch))
# Fetch issues print(json.dumps(all_results, indent=2))
print(f"Fetching {args.state} issues via GraphQL...", file=sys.stderr)
issues = fetch_milestone_issues(ms["number"], gql_states)
print(f"Fetched {len(issues)} issues total", file=sys.stderr)
# Filter by excluded labels
if args.exclude:
exclusions = set(label.strip() for label in args.exclude.split(","))
filtered = [issue for issue in issues
if not any(lbl in exclusions for lbl in issue["labels"])]
print(f"After excluding labels: {len(filtered)} issues", file=sys.stderr)
issues = filtered
# Filter to issues NOT yet in the comparison file (if --compare given)
if args.compare:
existing_nums = load_existing_issue_numbers(args.compare)
missing = [iss for iss in issues if iss["number"] not in existing_nums]
missing.sort(key=lambda x: x["number"])
print(f"Issues not yet in changelog: {len(missing)}", file=sys.stderr)
issues = missing
print(json.dumps(issues, indent=2))
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
@ -228,6 +331,8 @@ query($owner: String!, $repo: String!, $milestone: Int!, $cursor: String) {
title title
state state
mergedAt mergedAt
milestone { title }
baseRefName
author { login } author { login }
labels(first: 20) { nodes { name } } labels(first: 20) { nodes { name } }
closingIssuesReferences(first: 5) { nodes { number } } closingIssuesReferences(first: 5) { nodes { number } }
@ -275,6 +380,8 @@ def fetch_milestone_prs(milestone_num: int, states: str) -> list[dict]:
"title": node["title"], "title": node["title"],
"state": node["state"], "state": node["state"],
"merged_at": node.get("mergedAt"), "merged_at": node.get("mergedAt"),
"milestone": node["milestone"]["title"] if node.get("milestone") else None,
"base_ref": node.get("baseRefName"),
"author": node["author"]["login"] if node["author"] else None, "author": node["author"]["login"] if node["author"] else None,
"labels": [lbl["name"] for lbl in node["labels"]["nodes"]], "labels": [lbl["name"] for lbl in node["labels"]["nodes"]],
"closing_issues": [iss["number"] for iss in node["closingIssuesReferences"]["nodes"]], "closing_issues": [iss["number"] for iss in node["closingIssuesReferences"]["nodes"]],
@ -304,6 +411,8 @@ GQL_PRS_QUERY_ITEM = """\
state state
mergedAt mergedAt
createdAt createdAt
milestone {{ title }}
baseRefName
author {{ login }} author {{ login }}
labels(first: 20) {{ nodes {{ name }} }} labels(first: 20) {{ nodes {{ name }} }}
closingIssuesReferences(first: 5) {{ nodes {{ number }} }} closingIssuesReferences(first: 5) {{ nodes {{ number }} }}
@ -351,6 +460,8 @@ def fetch_prs_batch(pr_numbers: list[int]) -> list[dict]:
"state": pr["state"], "state": pr["state"],
"merged_at": pr.get("mergedAt"), "merged_at": pr.get("mergedAt"),
"created_at": pr.get("createdAt"), "created_at": pr.get("createdAt"),
"milestone": pr["milestone"]["title"] if pr.get("milestone") else None,
"base_ref": pr.get("baseRefName"),
"author": pr["author"]["login"] if pr["author"] else None, "author": pr["author"]["login"] if pr["author"] else None,
"labels": [lbl["name"] for lbl in pr["labels"]["nodes"]], "labels": [lbl["name"] for lbl in pr["labels"]["nodes"]],
"closing_issues": [iss["number"] for iss in pr["closingIssuesReferences"]["nodes"]], "closing_issues": [iss["number"] for iss in pr["closingIssuesReferences"]["nodes"]],
@ -431,11 +542,21 @@ def main() -> None:
sub = parser.add_subparsers(dest="command", required=True, title="subcommands") sub = parser.add_subparsers(dest="command", required=True, title="subcommands")
# --- issues --- # --- issues ---
p_issues = sub.add_parser("issues", help="List issues in a milestone") p_issues = sub.add_parser(
p_issues.add_argument("milestone", help="Milestone title, e.g. '2.16.0'") "issues",
help="Fetch issues by number (batch) or list issues in a milestone"
)
p_issues.add_argument(
"numbers", type=int, nargs="*",
help="Issue numbers to fetch (space-separated, batch mode)"
)
p_issues.add_argument(
"--milestone", type=str,
help="Milestone title to list all issues from (e.g. '2.16.0')"
)
p_issues.add_argument( p_issues.add_argument(
"--state", choices=["open", "closed", "all"], default="closed", "--state", choices=["open", "closed", "all"], default="closed",
help="Issue state filter (default: closed)" help="Issue state filter when using --milestone (default: closed)"
) )
p_issues.add_argument( p_issues.add_argument(
"--exclude", "--exclude-labels", "--exclude", "--exclude-labels",
@ -445,6 +566,14 @@ def main() -> None:
"--compare", "--compare",
help="Path to CHANGES.md; only show issues NOT yet referenced in that file" help="Path to CHANGES.md; only show issues NOT yet referenced in that file"
) )
p_issues.add_argument(
"--file", type=str,
help="File with one issue number per line"
)
p_issues.add_argument(
"--stdin", action="store_true",
help="Read issue numbers from stdin (one per line)"
)
p_issues.set_defaults(func=cmd_issues) p_issues.set_defaults(func=cmd_issues)
# --- prs --- # --- prs ---