diff --git a/.opencode/skills/gh-issue-from-pr/SKILL.md b/.opencode/skills/gh-issue-from-pr/SKILL.md index 012d58f7be..0500db6682 100644 --- a/.opencode/skills/gh-issue-from-pr/SKILL.md +++ b/.opencode/skills/gh-issue-from-pr/SKILL.md @@ -1,8 +1,3 @@ ---- -name: gh-issue-from-pr -description: Create a user-facing GitHub issue from a PR, separating the WHAT from the HOW, with correct milestone, project, labels, and issue type. ---- - # Skill: gh-issue-from-pr Create a GitHub issue that captures the **WHAT** (user-facing feature or @@ -18,15 +13,43 @@ Used when the project board needs an issue as the primary changelog/release unit ## Prerequisites - `gh` CLI authenticated (`gh auth status`) +- `tools/gh.py` helper script available - Permission to create issues and edit PRs in the target repository ## Workflow ### 1. Understand the PR +Use `gh.py` to fetch all PR details in a single batched GraphQL call +(avoids N+1 API calls and respects rate limits): + ```bash -gh pr view --repo penpot/penpot \ - --json title,body,author,labels,baseRefName,mergedAt,state,milestone +python3 tools/gh.py prs +``` + +This returns JSON with: `number`, `title`, `body`, `state`, `merged_at`, +`created_at`, `milestone`, `base_ref`, `author`, `labels`, `closing_issues`. + +Extract individual fields without parsing the full JSON twice: + +```bash +# Title +python3 tools/gh.py prs | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['title'])" + +# Author +python3 tools/gh.py prs | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['author'])" + +# Milestone +python3 tools/gh.py prs | python3 -c "import sys,json; print(json.load(sys.stdin)[0].get('milestone'))" + +# Labels +python3 tools/gh.py prs | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['labels'])" + +# Base branch +python3 tools/gh.py prs | python3 -c "import sys,json; print(json.load(sys.stdin)[0].get('base_ref'))" + +# Body (for extracting user-facing description) +python3 tools/gh.py prs | python3 -c "import sys,json; print(json.load(sys.stdin)[0].get('body',''))" ``` Identify: @@ -38,10 +61,10 @@ Identify: ### 2. Determine metadata | Field | Source | Rule | -|-------|--------|------| +|-------|--------|-------| | **Title** | PR title | Rewrite from user perspective. Strip leading emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`). Focus on observable behavior. Use imperative mood. | | **Labels** | PR labels | Copy user-facing labels (`bug`, `enhancement`, `community contribution`). Skip workflow labels (`backport candidate`, `team-qa`). | -| **Milestone** | PR milestone | **Always copy what's on the PR.** Fetch with: `gh pr view --json milestone --jq '.milestone.title'` If the PR has no milestone, create the issue without one. | +| **Milestone** | PR milestone | **Always copy what's on the PR.** Fetch with: `python3 tools/gh.py prs \| python3 -c "import sys,json; print(json.load(sys.stdin)[0].get('milestone'))"`. If the PR has no milestone, create the issue without one. | | **Project** | Always `Main` | Penpot uses the `Main` project (number 8) for all issues. | | **Body** | PR's user-facing section | Extract steps to reproduce or feature description. Omit internal details. Use templates below. | | **Issue Type** | PR labels / title | Map: `bug` label or `:bug:` title → `Bug`. `enhancement` label or `:sparkles:` title → `Enhancement`. Feature/epic → `Feature`. Default → `Task`. | @@ -115,7 +138,7 @@ Output: `https://github.com/penpot/penpot/issues/` Assign the issue to the PR author so they're responsible for it: ```bash -AUTHOR=$(gh pr view --repo penpot/penpot --json author --jq '.author.login') +AUTHOR=$(python3 tools/gh.py prs | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['author'])") gh issue edit --repo penpot/penpot --add-assignee "$AUTHOR" ``` @@ -175,7 +198,12 @@ query { repository(owner: "penpot", name: "penpot") { Append `Closes #` to the PR body: ```bash -gh pr view --repo penpot/penpot --json body --jq '.body' > /tmp/pr-body.md +# Fetch current body via gh.py +python3 tools/gh.py prs | python3 -c " +import sys, json +print(json.load(sys.stdin)[0].get('body', '')) +" > /tmp/pr-body.md + printf "\n\nCloses #\n" >> /tmp/pr-body.md gh pr edit --repo penpot/penpot --body-file /tmp/pr-body.md @@ -228,3 +256,7 @@ rm -f /tmp/issue-body.md /tmp/pr-body.md single issue that summarizes the overall change. - **Community attribution:** if the PR has the `community contribution` label or the author is not a core team member, add the label to the issue. +- **Always use `tools/gh.py prs` for fetching PR data.** Never use + `gh pr view --json ...` directly — `gh.py` batches requests and + respects rate limits. If you need a field not exposed by `gh.py`, + add it to the script instead of calling `gh` directly. diff --git a/.opencode/skills/update-changelog/SKILL.md b/.opencode/skills/update-changelog/SKILL.md index a65e90a607..bcfd377b40 100644 --- a/.opencode/skills/update-changelog/SKILL.md +++ b/.opencode/skills/update-changelog/SKILL.md @@ -102,9 +102,17 @@ 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). + +> **⚠️ 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. Categorize entries — strictly by issue type, never by labels or emoji 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 ---