📎 Update the 'update-changelog' skill

And add specific tool for extracting info from github
This commit is contained in:
Andrey Antukh 2026-05-14 19:17:14 +02:00
parent 0b65431137
commit 1f8ab6fed2
2 changed files with 463 additions and 62 deletions

View File

@ -7,7 +7,7 @@ description: Update the project CHANGES.md with issues from a given GitHub miles
Update `CHANGES.md` with entries for all issues and PRs in a given GitHub
milestone. Each entry references the user-facing issue (not the PR) as the
primary link, with the fix PR on a sub-line.
primary link, with the fix PR inline on the same line.
## When to Use
@ -19,7 +19,8 @@ primary link, with the fix PR on a sub-line.
## Prerequisites
- `gh` CLI authenticated (`gh auth status`)
- Read access to the penpot/penpot repository
- Python 3.8+
- `tools/gh.py` helper script available
## Workflow
@ -28,58 +29,64 @@ primary link, with the fix PR on a sub-line.
The version is typically a semver string like `2.15.3`. Confirm with the user
if not specified.
### 2. Fetch all issues and PRs in the milestone
### 2. Fetch all issues in the milestone
Find the milestone number:
Use the helper script. It uses GraphQL for efficient single-pass fetching
(closing PRs are included in the same query — no N+1):
```bash
gh api repos/penpot/penpot/milestones --paginate \
--jq '.[] | select(.title=="<VERSION>") | {number: .number, title: .title, open_issues: .open_issues, closed_issues: .closed_issues}'
# All closed issues (default)
python3 tools/gh.py issues "2.16.0"
# Include open issues too
python3 tools/gh.py issues "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"
```
Then fetch all items:
**Label exclusion rules:**
- `release blocker` — Internal release-blocking bugs not relevant to end users
- `no changelog` — Chore/refactor work that doesn't need a changelog entry
The script outputs JSON with each entry containing `number`, `title`, `state`,
`labels`, and `closing_prs` (the PRs that fix each issue).
### 3. Identify missing entries (optional)
If updating from an existing `CHANGES.md`, find issues in the milestone that
are NOT yet referenced in the changelog:
```bash
MILESTONE_NUMBER=<NUMBER>
gh api "repos/penpot/penpot/issues?milestone=$MILESTONE_NUMBER&state=all&per_page=100" \
--jq '.[] | {number: .number, title: .title, state: .state, labels: [.labels[].name], pull_request: .pull_request != null}'
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" --compare CHANGES.md
```
### 3. Identify issue ↔ PR relationships
This returns a filtered JSON array with only the missing issues.
For each item, determine the relationship:
### 4. Fetch additional PR details when needed
- **Issue** (`pull_request: false`): This is the user-facing issue. It
becomes the primary link in the changelog.
- **PR** (`pull_request: true`): Check if it has `Fixes #<NUMBER>` in its
body to find which issue it closes.
To find the linked issue for a PR:
When you need more context for specific PRs (e.g. to find the PR author for
community contribution attribution, or to read the PR body for
"Fixes/Closes #NNN" patterns):
```bash
gh pr view <PR_NUMBER> --repo penpot/penpot \
--json body,closingIssuesReferences --jq '{closingIssues: [.closingIssuesReferences[].number]}'
# One or more PR numbers
python3 tools/gh.py prs 9179 9204 9311
# From a file
python3 tools/gh.py prs --file prs.txt
# From stdin
cat prs.txt | python3 tools/gh.py prs --stdin
```
**Only closed issues are included.** An issue must have `state: "closed"` to
appear in the changelog. Open/unresolved issues are omitted, even if they are
tracked in the milestone.
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.
**Pairing rules:**
### 5. Categorize entries
| Pattern | Changelog format |
|---------|-----------------|
| Closed issue + one or more PRs fix it | Primary link = issue, sub-line with PRs comma-separated |
| PR exists with no linked issue | If a corresponding closed issue exists in the same milestone, link the issue. Otherwise, skip the entry (the issue must be the changelog unit). |
| Closed issue with no fix PR in milestone | Link the issue directly, without a PR sub-line. |
### 4. Categorize entries
Check the labels on each issue/PR:
```bash
gh issue view <NUMBER> --repo penpot/penpot --json labels --jq '[.labels[].name]'
```
Check the labels on each issue to determine which section it belongs to:
| Label / Title prefix | Changelog section |
|----------------------|-------------------|
@ -90,19 +97,32 @@ gh issue view <NUMBER> --repo penpot/penpot --json labels --jq '[.labels[].name]
**Community contribution attribution:** If the issue or its fix PR has the
`community contribution` label, add an attribution `(by @<github_username>)`
on the changelog entry line, **before** the GitHub issue/PR references.
Fetch the author:
The attribution should reference the **PR author**, not the issue author.
The `prs` subcommand includes the `author` field — use that:
```bash
gh issue view <NUMBER> --repo penpot/penpot --json author --jq '.author.login'
python3 tools/gh.py prs <PR_NUMBER> | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['author'])"
```
Placement in the entry line:
```markdown
- Fix description of the bug (by @username) [Github #<ISSUE>](...)
(PR: [#<PR>](...))
- Fix description of the bug (by @username) [#<ISSUE>](...) (PR: [#<PR>](...))
```
### 5. Read the current CHANGES.md
**Only closed issues are included.** An issue must have `state: "closed"` to
appear in the changelog. Open/unresolved issues are omitted, even if they are
tracked in the milestone.
**Pairing rules:**
| Pattern | Changelog format |
|---------|-----------------|
| Closed issue + one or more PRs fix it | Primary link = issue, PR inline comma-separated |
| PR exists with no linked issue | If a corresponding closed issue exists in the same milestone, link the issue. Otherwise, skip the entry (the issue must be the changelog unit). |
| Closed issue with no fix PR in milestone | Link the issue directly, without a PR reference. |
### 6. 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`
@ -115,30 +135,29 @@ Key format rules from the existing file:
### :bug: Bugs fixed
- Fix description of the bug [Github #<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>)
(PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
- Fix another bug (by @contributor) [Github #<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>)
(PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
- Fix description of the bug [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
- Fix another bug (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
### :sparkles: New features & Enhancements
- Add new feature description [Github #<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>)
(PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
- Add new feature description [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
```
Format details:
- Entries start with `- ` followed by a short description in imperative mood
- Primary link is **always the issue** (user-facing artifact)
- PR references are on an indented sub-line: ` (PR: [#<N>](<url>))`
If an issue has multiple fix PRs, they are comma-separated on one line:
` (PR: [#<N>](<url>), [#<M>](<url>))`
- PR references are inline on the same line: `(PR: [#<N>](<url>))`
If an issue has multiple fix PRs, they are comma-separated:
`(PR: [#<N>](<url>), [#<M>](<url>))`
- The description should describe the fix/feature from the user's perspective
- Community contributions get `(by @<username>)` **before** the GitHub link
- Community contributions get `(by @<username>)` **before** the issue link
- Sections are separated by a blank line between the last entry and the next
section title
- Only include a section if there are entries for it
- When an entry already exists in an earlier version section, it must be removed
from the current version to avoid duplicates
### 6. Build the description text
### 7. 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
@ -152,13 +171,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` |
### 7. Insert the section into CHANGES.md
### 8. 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.
### 8. Verify
### 9. Verify
Read the top of `CHANGES.md` and confirm:
- The version header is correct
@ -174,17 +193,15 @@ Read the top of `CHANGES.md` and confirm:
### :bug: Bugs fixed
- <fix description> [Github #<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>)
(PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
- <fix description> (by @contributor) [Github #<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>)
(PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
- <fix description> [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
- <fix description> (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
```
## Key Principles
- **Issue = changelog unit.** The primary link always points to the
user-facing issue, not the implementation PR.
- **PR = implementation detail.** Reference the PR on a sub-line so readers
- **PR = implementation detail.** Reference the PR inline so readers
can find the code changes.
- **Latest version first.** New sections are inserted at the top of the
changelog, below the `# CHANGELOG` header.
@ -192,10 +209,24 @@ Read the top of `CHANGES.md` and confirm:
what broke and what was fixed, not internal implementation details.
- **Community attribution.** When the issue or fix PR has the
`community contribution` label, add `(by @<username>)` on the entry line
between the description and the GitHub link.
between the description and the issue link. Use the **PR author** (not the
issue author) for the attribution.
- **Only closed issues.** An issue must have `state: "closed"` to appear in
the changelog. Open unresolved issues are omitted.
- **Excluded labels.** Issues with `release blocker` or `no changelog` labels
must be excluded from the changelog.
- **Multiple PRs per issue.** If multiple PRs fix the same issue, list them
comma-separated on the same sub-line: `(PR: [#A](url), [#B](url))`.
comma-separated inline: `(PR: [#A](url), [#B](url))`.
- **Duplicate removal.** If an entry already exists in a prior version section,
remove it from the current version. Check for text-level duplicates (after
stripping links and attributions) across version sections.
- **Taiga references.** If a changelog entry references a Taiga URL
(`tree.taiga.io`), attempt to find a corresponding GitHub issue via the
Taiga description text or by searching GitHub PRs that reference the Taiga
URL. Replace the Taiga reference with the GitHub issue link and add the PR
reference if applicable.
- **Re-fetch before editing.** Milestones can change — always re-fetch issues
before making edits, don't rely on cached data.
- **Use `tools/gh.py`.** Prefer the helper script over raw `gh api` calls for
milestone issue listing and PR detail fetching. It handles GraphQL
pagination, batching, and label filtering automatically.

370
tools/gh.py Executable file
View File

@ -0,0 +1,370 @@
#!/usr/bin/env python3
"""
gh.py Multi-purpose CLI helper for penpot/penpot GitHub operations.
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
Usage:
python3 tools/gh.py issues <milestone-title> (default: state=closed)
python3 tools/gh.py issues "2.16.0" --state all
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
python3 tools/gh.py issues "2.16.0" --compare CHANGES.md
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
Prerequisites:
- gh CLI authenticated (gh auth status)
- Python 3.8+
"""
import argparse
import json
import re
import subprocess
import sys
from typing import Any
REPO = "penpot/penpot"
OWNER = "penpot"
REPO_NAME = "penpot"
# ─────────────────────────────────────────────
# Shared helpers
# ─────────────────────────────────────────────
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})
cmd = ["gh", "api", "graphql", "--input", "-"]
result = subprocess.run(cmd, input=payload, capture_output=True, text=True)
if result.returncode != 0:
print(f"gh error: {result.stderr}", file=sys.stderr)
sys.exit(1)
body = json.loads(result.stdout)
if "errors" in body:
for err in body["errors"]:
print(f"GraphQL error: {err.get('message')}", file=sys.stderr)
sys.exit(1)
return body["data"]
# ─────────────────────────────────────────────
# Subcommand: issues
# ─────────────────────────────────────────────
GQL_ISSUES_QUERY = """\
query($owner: String!, $repo: String!, $milestone: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
milestone(number: $milestone) {
issues(first: 100, after: $cursor, states: __STATES__) {
totalCount
pageInfo { hasNextPage endCursor }
nodes {
... on Issue {
number
title
state
labels(first: 20) { nodes { name } }
closedByPullRequestsReferences(first: 5) { nodes { number } }
}
}
}
}
}
}
"""
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.
Args:
milestone_num: milestone number
states: GraphQL states enum array literal, e.g. ``"[CLOSED]"`` or ``"[OPEN CLOSED]"``
Returns:
List of {number, title, state, labels: [str], closing_prs: [int]}
"""
query = GQL_ISSUES_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)
issues = data["repository"]["milestone"]["issues"]
page_info = issues["pageInfo"]
for node in issues["nodes"]:
if node is None:
continue
all_nodes.append({
"number": node["number"],
"title": node["title"],
"state": node["state"],
"labels": [lbl["name"] for lbl in node["labels"]["nodes"]],
"closing_prs": [pr["number"] for pr in node["closedByPullRequestsReferences"]["nodes"]],
})
total = len(all_nodes)
print(f" ... fetched {total} issues so far", file=sys.stderr)
if not page_info["hasNextPage"]:
break
cursor = page_info["endCursor"]
return all_nodes
def load_existing_issue_numbers(filepath: str) -> set[int]:
"""Parse all ``#NNNN`` references from a file (e.g. CHANGES.md)."""
pattern = re.compile(r"#(\d{3,5})\b")
nums: set[int] = set()
with open(filepath) as f:
for line in f:
for m in pattern.finditer(line):
nums.add(int(m.group(1)))
return nums
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",
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]
# 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))
# ─────────────────────────────────────────────
# Subcommand: prs
# ─────────────────────────────────────────────
PRS_BATCH_SIZE = 50
GQL_PRS_QUERY_ITEM = """\
pr_{num}: pullRequest(number: {num}) {{
number
title
body
state
mergedAt
createdAt
author {{ login }}
labels(first: 20) {{ nodes {{ name }} }}
closingIssuesReferences(first: 5) {{ nodes {{ number }} }}
}}
"""
GQL_PRS_QUERY_WRAPPER = """\
query($owner: String!, $repo: String!) {{
repository(owner: $owner, name: $repo) {{
{items}
}}
}}
"""
def fetch_prs_batch(pr_numbers: list[int]) -> list[dict]:
"""
Fetch details for a list of PR numbers in a single GraphQL query.
Uses numbered aliases (pr_1234, pr_5678, ) so each PR is looked up by
number in one round-trip. Returns entries in the same order as the input.
"""
items = "\n".join(
GQL_PRS_QUERY_ITEM.format(num=n) for n in pr_numbers
)
query = GQL_PRS_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 pr_numbers:
pr = repo.get(f"pr_{num}")
if pr is None:
results.append({
"number": num,
"error": "not_found",
})
continue
results.append({
"number": pr["number"],
"title": pr["title"],
"body": pr.get("body"),
"state": pr["state"],
"merged_at": pr.get("mergedAt"),
"created_at": pr.get("createdAt"),
"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"]],
})
return results
def cmd_prs(args: argparse.Namespace) -> None:
"""Handle the ``prs`` subcommand."""
# Collect PR numbers from args / file / stdin
pr_numbers: list[int] = []
if args.numbers:
pr_numbers.extend(args.numbers)
if args.file:
with open(args.file) as f:
for line in f:
line = line.strip()
if line:
pr_numbers.append(int(line))
if args.stdin:
for line in sys.stdin:
line = line.strip()
if line:
pr_numbers.append(int(line))
if not pr_numbers:
print("ERROR: no PR numbers provided (pass numbers, --file, or --stdin)",
file=sys.stderr)
sys.exit(1)
# Deduplicate while preserving order
seen: set[int] = set()
pr_numbers = [n for n in pr_numbers if not (n in seen or seen.add(n))]
print(f"Fetching {len(pr_numbers)} PRs in batches of {PRS_BATCH_SIZE}...",
file=sys.stderr)
all_results: list[dict] = []
for i in range(0, len(pr_numbers), PRS_BATCH_SIZE):
batch = pr_numbers[i : i + PRS_BATCH_SIZE]
print(f" batch {i // PRS_BATCH_SIZE + 1}: PRs {batch[0]}..{batch[-1]}",
file=sys.stderr)
all_results.extend(fetch_prs_batch(batch))
print(json.dumps(all_results, indent=2))
# ─────────────────────────────────────────────
# CLI entrypoint
# ─────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(
description="Multi-purpose CLI helper for penpot/penpot GitHub operations"
)
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.add_argument(
"--state", choices=["open", "closed", "all"], default="closed",
help="Issue state filter (default: closed)"
)
p_issues.add_argument(
"--exclude", "--exclude-labels",
help="Comma-separated labels to exclude, e.g. 'release blocker,no changelog'"
)
p_issues.add_argument(
"--compare",
help="Path to CHANGES.md; only show issues NOT yet referenced in that file"
)
p_issues.set_defaults(func=cmd_issues)
# --- prs ---
p_prs = sub.add_parser("prs", help="Fetch details for one or more PRs")
p_prs.add_argument(
"numbers", type=int, nargs="*",
help="PR numbers to fetch (space-separated)"
)
p_prs.add_argument(
"--file", type=str,
help="File with one PR number per line"
)
p_prs.add_argument(
"--stdin", action="store_true",
help="Read PR numbers from stdin (one per line)"
)
p_prs.set_defaults(func=cmd_prs)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()