Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh 2026-06-02 10:38:54 +02:00
commit 7517ba1559
4 changed files with 257 additions and 335 deletions

View File

@ -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 <N1> <N2> ...`** 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:
- <fix description> (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
```
### 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 "<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'## <MILESTONE> \(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 "<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

View File

@ -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

View File

@ -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)

View File

@ -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 <milestone-title> (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()