diff --git a/.opencode/skills/update-changelog/SKILL.md b/.opencode/skills/update-changelog/SKILL.md index 889e77c3e6..5752b65aa3 100644 --- a/.opencode/skills/update-changelog/SKILL.md +++ b/.opencode/skills/update-changelog/SKILL.md @@ -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=="") | {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= -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 #` 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 --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 --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 --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 @)` 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 --repo penpot/penpot --json author --jq '.author.login' +python3 tools/gh.py prs | 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 #](...) - (PR: [#](...)) +- Fix description of the bug (by @username) [#](...) (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 #](https://github.com/penpot/penpot/issues/) - (PR: [#](https://github.com/penpot/penpot/pull/)) -- Fix another bug (by @contributor) [Github #](https://github.com/penpot/penpot/issues/) - (PR: [#](https://github.com/penpot/penpot/pull/)) +- Fix description of the bug [#](https://github.com/penpot/penpot/issues/) (PR: [#](https://github.com/penpot/penpot/pull/)) +- Fix another bug (by @contributor) [#](https://github.com/penpot/penpot/issues/) (PR: [#](https://github.com/penpot/penpot/pull/)) ### :sparkles: New features & Enhancements -- Add new feature description [Github #](https://github.com/penpot/penpot/issues/) - (PR: [#](https://github.com/penpot/penpot/pull/)) +- Add new feature description [#](https://github.com/penpot/penpot/issues/) (PR: [#](https://github.com/penpot/penpot/pull/)) ``` 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: [#]())` - If an issue has multiple fix PRs, they are comma-separated on one line: - ` (PR: [#](), [#]())` +- PR references are inline on the same line: `(PR: [#]())` + If an issue has multiple fix PRs, they are comma-separated: + `(PR: [#](), [#]())` - The description should describe the fix/feature from the user's perspective -- Community contributions get `(by @)` **before** the GitHub link +- Community contributions get `(by @)` **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 -- [Github #](https://github.com/penpot/penpot/issues/) - (PR: [#](https://github.com/penpot/penpot/pull/)) -- (by @contributor) [Github #](https://github.com/penpot/penpot/issues/) - (PR: [#](https://github.com/penpot/penpot/pull/)) +- [#](https://github.com/penpot/penpot/issues/) (PR: [#](https://github.com/penpot/penpot/pull/)) +- (by @contributor) [#](https://github.com/penpot/penpot/issues/) (PR: [#](https://github.com/penpot/penpot/pull/)) ``` ## 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 @)` 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. diff --git a/tools/gh.py b/tools/gh.py new file mode 100755 index 0000000000..2aa7ac4da8 --- /dev/null +++ b/tools/gh.py @@ -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 (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()