mirror of
https://github.com/penpot/penpot.git
synced 2026-06-09 08:52:05 +00:00
Merge remote-tracking branch 'origin/staging' into develop
This commit is contained in:
commit
7517ba1559
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
454
tools/gh.py
454
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 <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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user