From bee1a896980205effd9cb00854dbd5cd349f0a06 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 27 May 2026 17:44:50 +0200 Subject: [PATCH] :sparkles: Add minor usability improvements to update-changelog skill --- .opencode/skills/update-changelog/SKILL.md | 67 +++++-- tools/gh.py | 193 +++++++++++++++++---- 2 files changed, 214 insertions(+), 46 deletions(-) diff --git a/.opencode/skills/update-changelog/SKILL.md b/.opencode/skills/update-changelog/SKILL.md index a65e90a607..365691a3b5 100644 --- a/.opencode/skills/update-changelog/SKILL.md +++ b/.opencode/skills/update-changelog/SKILL.md @@ -36,13 +36,13 @@ Use the helper script. It uses GraphQL for efficient single-pass fetching ```bash # 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 -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 -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):** @@ -65,7 +65,7 @@ If updating from an existing `CHANGES.md`, find issues in the milestone that are NOT yet referenced in the changelog: ```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. @@ -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`, -`merged_at`, `author`, `labels`, and `closing_issues`. PRs are fetched in -batches of 50 via GraphQL to stay within API limits (milestone mode uses -paginated GraphQL on the milestone's `pullRequests` connection). +`merged_at`, `milestone`, `author`, `labels`, and `closing_issues`. PRs are +fetched in batches of 50 via GraphQL to stay within API limits (milestone mode +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 ...`** 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 `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 > 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** (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. -### 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 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 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 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` | | `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 the previous version entry). Use the `edit` tool with enough context to make a unique match. -### 9. Verify +### 11. Verify Read the top of `CHANGES.md` and confirm: - The version header is correct @@ -251,7 +290,7 @@ Read the top of `CHANGES.md` and confirm: - The section ordering is correct (newest first) - 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 the milestone may not close any tracked issue. After writing, run a full diff --git a/tools/gh.py b/tools/gh.py index f35b5dd77b..a5b2bdeef6 100755 --- a/tools/gh.py +++ b/tools/gh.py @@ -174,41 +174,144 @@ def load_existing_issue_numbers(filepath: str) -> set[int]: 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: """Handle the ``issues`` subcommand.""" - # Resolve milestone - print(f"Looking up milestone \"{args.milestone}\"...", file=sys.stderr) - ms = find_milestone(args.milestone) - print(f"Milestone #{ms['number']}: {ms['open_issues']} open, {ms['closed_issues']} closed", + # ── Milestone mode: fetch all issues in a milestone ── + if args.milestone: + print(f"Looking up milestone \"{args.milestone}\"...", file=sys.stderr) + 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) - # Map state to GraphQL enum array literal - state_map = {"open": "[OPEN]", "closed": "[CLOSED]", "all": "[OPEN CLOSED]"} - gql_states = state_map[args.state] + all_results: list[dict] = [] + for i in range(0, len(issue_numbers), ISSUES_BATCH_SIZE): + 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(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)) + print(json.dumps(all_results, indent=2)) # ───────────────────────────────────────────── @@ -228,6 +331,8 @@ query($owner: String!, $repo: String!, $milestone: Int!, $cursor: String) { title state mergedAt + milestone { title } + baseRefName author { login } labels(first: 20) { nodes { name } } closingIssuesReferences(first: 5) { nodes { number } } @@ -275,6 +380,8 @@ def fetch_milestone_prs(milestone_num: int, states: str) -> list[dict]: "title": node["title"], "state": node["state"], "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, "labels": [lbl["name"] for lbl in node["labels"]["nodes"]], "closing_issues": [iss["number"] for iss in node["closingIssuesReferences"]["nodes"]], @@ -304,6 +411,8 @@ GQL_PRS_QUERY_ITEM = """\ state mergedAt createdAt + milestone {{ title }} + baseRefName author {{ login }} labels(first: 20) {{ nodes {{ name }} }} closingIssuesReferences(first: 5) {{ nodes {{ number }} }} @@ -351,6 +460,8 @@ def fetch_prs_batch(pr_numbers: list[int]) -> list[dict]: "state": pr["state"], "merged_at": pr.get("mergedAt"), "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, "labels": [lbl["name"] for lbl in pr["labels"]["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") # --- issues --- - p_issues = sub.add_parser("issues", help="List issues in a milestone") - p_issues.add_argument("milestone", help="Milestone title, e.g. '2.16.0'") + p_issues = sub.add_parser( + "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( "--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( "--exclude", "--exclude-labels", @@ -445,6 +566,14 @@ def main() -> None: "--compare", 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) # --- prs ---