diff --git a/.opencode/skills/update-changelog/SKILL.md b/.opencode/skills/update-changelog/SKILL.md index 365691a3b5..3ebe266f35 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 --milestone "2.16.0" +python3 tools/gh.py issues "2.16.0" # Include open issues too -python3 tools/gh.py issues --milestone "2.16.0" --state all +python3 tools/gh.py issues "2.16.0" --state all # Exclude entries that should not go in the changelog -python3 tools/gh.py issues --milestone "2.16.0" --exclude "release blocker,no changelog" +python3 tools/gh.py issues "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 --milestone "2.16.0" --exclude "release blocker,no changelog" --compare CHANGES.md +python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" --compare CHANGES.md ``` This returns a filtered JSON array with only the missing issues. @@ -102,50 +102,27 @@ python3 tools/gh.py prs --milestone "2.16.0" --state all ``` The `prs` command returns JSON with `number`, `title`, `body`, `state`, -`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). +`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). -> **⚠️ 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: +You can also list all PRs in a milestone in a single call: ```bash -# One or more issue numbers -python3 tools/gh.py issues 9010 9899 9900 +# All merged PRs in a milestone (default) +python3 tools/gh.py prs --milestone "2.16.0" -# From a file -python3 tools/gh.py issues --file issues.txt +# All states (merged, open, closed) +python3 tools/gh.py prs --milestone "2.16.0" --state all -# From stdin -cat issues.txt | python3 tools/gh.py issues --stdin +# Open PRs only +python3 tools/gh.py prs --milestone "2.16.0" --state open ``` -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). +The milestone path uses paginated GraphQL on the milestone's `pullRequests` +connection (100 per page), avoiding one-by-one fetches. -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 +### 5. 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 @@ -202,7 +179,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. -### 7. ⚠️ Verify PR merge status before writing +### 5a. ⚠️ 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 @@ -226,7 +203,7 @@ superseded it: Replace the reference in the changelog entry with the correct merged PR number. -### 8. Read the current CHANGES.md +### 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` @@ -261,7 +238,7 @@ Format details: - When an entry already exists in an earlier version section, it must be removed from the current version to avoid duplicates -### 9. 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 @@ -275,13 +252,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` | -### 10. 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. -### 11. Verify +### 9. Verify Read the top of `CHANGES.md` and confirm: - The version header is correct @@ -290,7 +267,7 @@ Read the top of `CHANGES.md` and confirm: - The section ordering is correct (newest first) - Formatting matches the surrounding entries -### 12. Cross-reference milestone PRs against the changelog +### 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 @@ -367,6 +344,72 @@ if closed: - (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/CHANGES.md b/CHANGES.md index de9e1d2a98..c92d32ff16 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -213,6 +213,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 diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index 4cb9a503ae..020bc2a490 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -173,7 +173,7 @@ [:> layers-tree* props])) (mf/defc filters-tree* - {::mf/wrap [mf/memo #(mf/throttle % 300)] + {::mf/wrap [mf/memo #(mf/throttle % 50)] ::mf/private true} [{:keys [objects parent-size]}] (let [selected (use-selected-shapes) diff --git a/tools/gh.py b/tools/gh.py index a5b2bdeef6..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 (or all PRs in a milestone) + prs Fetch details for one or more PRs (by number or milestone) Usage: python3 tools/gh.py issues (default: state=closed) @@ -42,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}) @@ -71,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 # ───────────────────────────────────────────── @@ -99,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. @@ -174,231 +184,45 @@ 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.""" - # ── 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}...", + # 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) - 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)) + # Map state to GraphQL enum array literal + state_map = {"open": "[OPEN]", "closed": "[CLOSED]", "all": "[OPEN CLOSED]"} + gql_states = state_map[args.state] - print(json.dumps(all_results, indent=2)) + # 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 — milestone PR listing -# ───────────────────────────────────────────── - -GQL_MILESTONE_PRS_QUERY = """\ -query($owner: String!, $repo: String!, $milestone: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - milestone(number: $milestone) { - pullRequests(first: 50, after: $cursor, states: __STATES__) { - totalCount - pageInfo { hasNextPage endCursor } - nodes { - ... on PullRequest { - number - title - state - mergedAt - milestone { title } - baseRefName - 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 PRs in a milestone via paginated GraphQL. - - Args: - milestone_num: milestone number - states: GraphQL states enum array literal for pullRequests, - e.g. ``"[MERGED]"`` or ``"[OPEN CLOSED MERGED]"`` - - Returns: - List of {number, title, state, merged_at, author, labels, closing_issues} - """ - 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"], - "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"]], - }) - - 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 - - -# ───────────────────────────────────────────── -# Subcommand: prs — batch fetch by number +# Subcommand: prs # ───────────────────────────────────────────── PRS_BATCH_SIZE = 50 @@ -411,8 +235,6 @@ GQL_PRS_QUERY_ITEM = """\ state mergedAt createdAt - milestone {{ title }} - baseRefName author {{ login }} labels(first: 20) {{ nodes {{ name }} }} closingIssuesReferences(first: 5) {{ nodes {{ number }} }} @@ -460,8 +282,6 @@ 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"]], @@ -469,27 +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.""" - # ── Milestone mode: fetch all PRs in a milestone ── + # ── Milestone path ────────────────────────────────────────────── 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]", - "merged": "[MERGED]", "all": "[OPEN CLOSED MERGED]"} + 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) - results = fetch_milestone_prs(ms["number"], gql_states) - print(f"Fetched {len(results)} PRs total", file=sys.stderr) - print(json.dumps(results, indent=2)) + 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 - # ── Batch mode: fetch specific PRs by number ── + # ── Number-based path ─────────────────────────────────────────── pr_numbers: list[int] = [] if args.numbers: @@ -542,21 +438,11 @@ def main() -> None: sub = parser.add_subparsers(dest="command", required=True, title="subcommands") # --- issues --- - 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 = 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 when using --milestone (default: closed)" + help="Issue state filter (default: closed)" ) p_issues.add_argument( "--exclude", "--exclude-labels", @@ -566,30 +452,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 --- - p_prs = sub.add_parser("prs", help="Fetch details for one or more PRs (or all PRs in a milestone)") + 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)" ) - p_prs.add_argument( - "--milestone", type=str, - help="Milestone title to list all PRs from (e.g. '2.16.0')" - ) - p_prs.add_argument( - "--state", choices=["open", "closed", "merged", "all"], default="merged", - help="PR state filter when using --milestone (default: merged)" - ) p_prs.add_argument( "--file", type=str, help="File with one PR number per line" @@ -598,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()