From 0d2e0f8367518a82ca1b589de613a616d6aca4d2 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 2 Jun 2026 10:09:53 +0200 Subject: [PATCH 1/3] :paperclip: Update the `update-changelog` skill and gh.py tool --- .opencode/skills/update-changelog/SKILL.md | 82 ++++++++++ tools/gh.py | 177 +++++++++++++++++---- 2 files changed, 227 insertions(+), 32 deletions(-) diff --git a/.opencode/skills/update-changelog/SKILL.md b/.opencode/skills/update-changelog/SKILL.md index 29c3d295b4..92fd74120d 100644 --- a/.opencode/skills/update-changelog/SKILL.md +++ b/.opencode/skills/update-changelog/SKILL.md @@ -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: - (by @contributor) [#](https://github.com/penpot/penpot/issues/) (PR: [#](https://github.com/penpot/penpot/pull/)) ``` +### 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 "" --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'## \(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 "" --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 diff --git a/tools/gh.py b/tools/gh.py index afd81da619..40433791c5 100755 --- a/tools/gh.py +++ b/tools/gh.py @@ -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 (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() From d49fa51fefc2faaafa69bd46ec273ba48d682731 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 2 Jun 2026 10:10:20 +0200 Subject: [PATCH 2/3] Update changelog --- CHANGES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.md b/CHANGES.md index fbef87a713..831a0da7ee 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,7 @@ ### :sparkles: New features & Enhancements - Add rate limiting and concurrency safety for file snapshot operations [#9723](https://github.com/penpot/penpot/issues/9723) (PR: [#9722](https://github.com/penpot/penpot/pull/9722)) +- Prevent concurrent font uploads from causing excessive simultaneous requests [#9922](https://github.com/penpot/penpot/issues/9922) (PR: [#9921](https://github.com/penpot/penpot/pull/9921)) ### :bug: Bugs fixed From cd18a2bcb203a5af0de8d3883e195eb16d0ac3da Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 2 Jun 2026 10:13:01 +0200 Subject: [PATCH 3/3] :paperclip: Update version on mcp/package.json --- mcp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/package.json b/mcp/package.json index bf3802c3d8..18d6eecb38 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@penpot/mcp", - "version": "2.15.0", + "version": "2.15.4", "description": "MCP server for Penpot integration", "bin": { "penpot-mcp": "./bin/mcp-local.js"