mirror of
https://github.com/penpot/penpot.git
synced 2026-05-15 21:13:58 +00:00
📎 Update the 'update-changelog' skill
And add specific tool for extracting info from github
This commit is contained in:
parent
0b65431137
commit
1f8ab6fed2
@ -7,7 +7,7 @@ description: Update the project CHANGES.md with issues from a given GitHub miles
|
||||
|
||||
Update `CHANGES.md` with entries for all issues and PRs in a given GitHub
|
||||
milestone. Each entry references the user-facing issue (not the PR) as the
|
||||
primary link, with the fix PR on a sub-line.
|
||||
primary link, with the fix PR inline on the same line.
|
||||
|
||||
## When to Use
|
||||
|
||||
@ -19,7 +19,8 @@ primary link, with the fix PR on a sub-line.
|
||||
## Prerequisites
|
||||
|
||||
- `gh` CLI authenticated (`gh auth status`)
|
||||
- Read access to the penpot/penpot repository
|
||||
- Python 3.8+
|
||||
- `tools/gh.py` helper script available
|
||||
|
||||
## Workflow
|
||||
|
||||
@ -28,58 +29,64 @@ primary link, with the fix PR on a sub-line.
|
||||
The version is typically a semver string like `2.15.3`. Confirm with the user
|
||||
if not specified.
|
||||
|
||||
### 2. Fetch all issues and PRs in the milestone
|
||||
### 2. Fetch all issues in the milestone
|
||||
|
||||
Find the milestone number:
|
||||
Use the helper script. It uses GraphQL for efficient single-pass fetching
|
||||
(closing PRs are included in the same query — no N+1):
|
||||
|
||||
```bash
|
||||
gh api repos/penpot/penpot/milestones --paginate \
|
||||
--jq '.[] | select(.title=="<VERSION>") | {number: .number, title: .title, open_issues: .open_issues, closed_issues: .closed_issues}'
|
||||
# All closed issues (default)
|
||||
python3 tools/gh.py issues "2.16.0"
|
||||
|
||||
# Include open issues too
|
||||
python3 tools/gh.py issues "2.16.0" --state all
|
||||
|
||||
# Exclude entries that should not go in the changelog
|
||||
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
|
||||
```
|
||||
|
||||
Then fetch all items:
|
||||
**Label exclusion rules:**
|
||||
- `release blocker` — Internal release-blocking bugs not relevant to end users
|
||||
- `no changelog` — Chore/refactor work that doesn't need a changelog entry
|
||||
|
||||
The script outputs JSON with each entry containing `number`, `title`, `state`,
|
||||
`labels`, and `closing_prs` (the PRs that fix each issue).
|
||||
|
||||
### 3. Identify missing entries (optional)
|
||||
|
||||
If updating from an existing `CHANGES.md`, find issues in the milestone that
|
||||
are NOT yet referenced in the changelog:
|
||||
|
||||
```bash
|
||||
MILESTONE_NUMBER=<NUMBER>
|
||||
gh api "repos/penpot/penpot/issues?milestone=$MILESTONE_NUMBER&state=all&per_page=100" \
|
||||
--jq '.[] | {number: .number, title: .title, state: .state, labels: [.labels[].name], pull_request: .pull_request != null}'
|
||||
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" --compare CHANGES.md
|
||||
```
|
||||
|
||||
### 3. Identify issue ↔ PR relationships
|
||||
This returns a filtered JSON array with only the missing issues.
|
||||
|
||||
For each item, determine the relationship:
|
||||
### 4. Fetch additional PR details when needed
|
||||
|
||||
- **Issue** (`pull_request: false`): This is the user-facing issue. It
|
||||
becomes the primary link in the changelog.
|
||||
- **PR** (`pull_request: true`): Check if it has `Fixes #<NUMBER>` in its
|
||||
body to find which issue it closes.
|
||||
|
||||
To find the linked issue for a PR:
|
||||
When you need more context for specific PRs (e.g. to find the PR author for
|
||||
community contribution attribution, or to read the PR body for
|
||||
"Fixes/Closes #NNN" patterns):
|
||||
|
||||
```bash
|
||||
gh pr view <PR_NUMBER> --repo penpot/penpot \
|
||||
--json body,closingIssuesReferences --jq '{closingIssues: [.closingIssuesReferences[].number]}'
|
||||
# One or more PR numbers
|
||||
python3 tools/gh.py prs 9179 9204 9311
|
||||
|
||||
# From a file
|
||||
python3 tools/gh.py prs --file prs.txt
|
||||
|
||||
# From stdin
|
||||
cat prs.txt | python3 tools/gh.py prs --stdin
|
||||
```
|
||||
|
||||
**Only closed issues are included.** An issue must have `state: "closed"` to
|
||||
appear in the changelog. Open/unresolved issues are omitted, even if they are
|
||||
tracked in the milestone.
|
||||
The `prs` command returns JSON with `number`, `title`, `body`, `state`,
|
||||
`merged_at`, `author`, `labels`, and `closing_issues`. PRs are fetched in
|
||||
batches of 50 via GraphQL to stay within API limits.
|
||||
|
||||
**Pairing rules:**
|
||||
### 5. Categorize entries
|
||||
|
||||
| Pattern | Changelog format |
|
||||
|---------|-----------------|
|
||||
| Closed issue + one or more PRs fix it | Primary link = issue, sub-line with PRs comma-separated |
|
||||
| PR exists with no linked issue | If a corresponding closed issue exists in the same milestone, link the issue. Otherwise, skip the entry (the issue must be the changelog unit). |
|
||||
| Closed issue with no fix PR in milestone | Link the issue directly, without a PR sub-line. |
|
||||
|
||||
### 4. Categorize entries
|
||||
|
||||
Check the labels on each issue/PR:
|
||||
|
||||
```bash
|
||||
gh issue view <NUMBER> --repo penpot/penpot --json labels --jq '[.labels[].name]'
|
||||
```
|
||||
Check the labels on each issue to determine which section it belongs to:
|
||||
|
||||
| Label / Title prefix | Changelog section |
|
||||
|----------------------|-------------------|
|
||||
@ -90,19 +97,32 @@ gh issue view <NUMBER> --repo penpot/penpot --json labels --jq '[.labels[].name]
|
||||
**Community contribution attribution:** If the issue or its fix PR has the
|
||||
`community contribution` label, add an attribution `(by @<github_username>)`
|
||||
on the changelog entry line, **before** the GitHub issue/PR references.
|
||||
Fetch the author:
|
||||
|
||||
The attribution should reference the **PR author**, not the issue author.
|
||||
The `prs` subcommand includes the `author` field — use that:
|
||||
|
||||
```bash
|
||||
gh issue view <NUMBER> --repo penpot/penpot --json author --jq '.author.login'
|
||||
python3 tools/gh.py prs <PR_NUMBER> | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['author'])"
|
||||
```
|
||||
|
||||
Placement in the entry line:
|
||||
```markdown
|
||||
- Fix description of the bug (by @username) [Github #<ISSUE>](...)
|
||||
(PR: [#<PR>](...))
|
||||
- Fix description of the bug (by @username) [#<ISSUE>](...) (PR: [#<PR>](...))
|
||||
```
|
||||
|
||||
### 5. Read the current CHANGES.md
|
||||
**Only closed issues are included.** An issue must have `state: "closed"` to
|
||||
appear in the changelog. Open/unresolved issues are omitted, even if they are
|
||||
tracked in the milestone.
|
||||
|
||||
**Pairing rules:**
|
||||
|
||||
| Pattern | Changelog format |
|
||||
|---------|-----------------|
|
||||
| Closed issue + one or more PRs fix it | Primary link = issue, PR inline comma-separated |
|
||||
| PR exists with no linked issue | If a corresponding closed issue exists in the same milestone, link the issue. Otherwise, skip the entry (the issue must be the changelog unit). |
|
||||
| Closed issue with no fix PR in milestone | Link the issue directly, without a PR reference. |
|
||||
|
||||
### 6. Read the current CHANGES.md
|
||||
|
||||
Read the top of `CHANGES.md` to understand the existing format and find the
|
||||
insertion point (newest version goes at the top, after the `# CHANGELOG`
|
||||
@ -115,30 +135,29 @@ Key format rules from the existing file:
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix description of the bug [Github #<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>)
|
||||
(PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
- Fix another bug (by @contributor) [Github #<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>)
|
||||
(PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
- Fix description of the bug [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
- Fix another bug (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
- Add new feature description [Github #<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>)
|
||||
(PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
- Add new feature description [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
```
|
||||
|
||||
Format details:
|
||||
- Entries start with `- ` followed by a short description in imperative mood
|
||||
- Primary link is **always the issue** (user-facing artifact)
|
||||
- PR references are on an indented sub-line: ` (PR: [#<N>](<url>))`
|
||||
If an issue has multiple fix PRs, they are comma-separated on one line:
|
||||
` (PR: [#<N>](<url>), [#<M>](<url>))`
|
||||
- PR references are inline on the same line: `(PR: [#<N>](<url>))`
|
||||
If an issue has multiple fix PRs, they are comma-separated:
|
||||
`(PR: [#<N>](<url>), [#<M>](<url>))`
|
||||
- The description should describe the fix/feature from the user's perspective
|
||||
- Community contributions get `(by @<username>)` **before** the GitHub link
|
||||
- Community contributions get `(by @<username>)` **before** the issue link
|
||||
- Sections are separated by a blank line between the last entry and the next
|
||||
section title
|
||||
- Only include a section if there are entries for it
|
||||
- When an entry already exists in an earlier version section, it must be removed
|
||||
from the current version to avoid duplicates
|
||||
|
||||
### 6. Build the description text
|
||||
### 7. Build the description text
|
||||
|
||||
Derive the description from the issue title, not the PR title. Strip leading
|
||||
emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`) and focus on the
|
||||
@ -152,13 +171,13 @@ Examples:
|
||||
| `Comment content is not sanitized before rendering, enabling stored XSS` | `Sanitize comment content on rendering` |
|
||||
| `Custom uploaded font family names are not sanitized` | `Sanitize font family names on custom uploaded fonts` |
|
||||
|
||||
### 7. Insert the section into CHANGES.md
|
||||
### 8. Insert the section into CHANGES.md
|
||||
|
||||
Insert the new version section right after the `# CHANGELOG` header (before
|
||||
the previous version entry). Use the `edit` tool with enough context to make
|
||||
a unique match.
|
||||
|
||||
### 8. Verify
|
||||
### 9. Verify
|
||||
|
||||
Read the top of `CHANGES.md` and confirm:
|
||||
- The version header is correct
|
||||
@ -174,17 +193,15 @@ Read the top of `CHANGES.md` and confirm:
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- <fix description> [Github #<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>)
|
||||
(PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
- <fix description> (by @contributor) [Github #<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>)
|
||||
(PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
- <fix description> [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
- <fix description> (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
```
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Issue = changelog unit.** The primary link always points to the
|
||||
user-facing issue, not the implementation PR.
|
||||
- **PR = implementation detail.** Reference the PR on a sub-line so readers
|
||||
- **PR = implementation detail.** Reference the PR inline so readers
|
||||
can find the code changes.
|
||||
- **Latest version first.** New sections are inserted at the top of the
|
||||
changelog, below the `# CHANGELOG` header.
|
||||
@ -192,10 +209,24 @@ Read the top of `CHANGES.md` and confirm:
|
||||
what broke and what was fixed, not internal implementation details.
|
||||
- **Community attribution.** When the issue or fix PR has the
|
||||
`community contribution` label, add `(by @<username>)` on the entry line
|
||||
between the description and the GitHub link.
|
||||
between the description and the issue link. Use the **PR author** (not the
|
||||
issue author) for the attribution.
|
||||
- **Only closed issues.** An issue must have `state: "closed"` to appear in
|
||||
the changelog. Open unresolved issues are omitted.
|
||||
- **Excluded labels.** Issues with `release blocker` or `no changelog` labels
|
||||
must be excluded from the changelog.
|
||||
- **Multiple PRs per issue.** If multiple PRs fix the same issue, list them
|
||||
comma-separated on the same sub-line: `(PR: [#A](url), [#B](url))`.
|
||||
comma-separated inline: `(PR: [#A](url), [#B](url))`.
|
||||
- **Duplicate removal.** If an entry already exists in a prior version section,
|
||||
remove it from the current version. Check for text-level duplicates (after
|
||||
stripping links and attributions) across version sections.
|
||||
- **Taiga references.** If a changelog entry references a Taiga URL
|
||||
(`tree.taiga.io`), attempt to find a corresponding GitHub issue via the
|
||||
Taiga description text or by searching GitHub PRs that reference the Taiga
|
||||
URL. Replace the Taiga reference with the GitHub issue link and add the PR
|
||||
reference if applicable.
|
||||
- **Re-fetch before editing.** Milestones can change — always re-fetch issues
|
||||
before making edits, don't rely on cached data.
|
||||
- **Use `tools/gh.py`.** Prefer the helper script over raw `gh api` calls for
|
||||
milestone issue listing and PR detail fetching. It handles GraphQL
|
||||
pagination, batching, and label filtering automatically.
|
||||
|
||||
370
tools/gh.py
Executable file
370
tools/gh.py
Executable file
@ -0,0 +1,370 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
gh.py — Multi-purpose CLI helper for penpot/penpot GitHub operations.
|
||||
|
||||
Uses GitHub GraphQL and REST APIs via the authenticated ``gh`` CLI.
|
||||
|
||||
Subcommands:
|
||||
issues List issues in a milestone
|
||||
prs Fetch details for one or more PRs
|
||||
|
||||
Usage:
|
||||
python3 tools/gh.py issues <milestone-title> (default: state=closed)
|
||||
python3 tools/gh.py issues "2.16.0" --state all
|
||||
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
|
||||
python3 tools/gh.py issues "2.16.0" --compare CHANGES.md
|
||||
python3 tools/gh.py prs 9179 9204 9311
|
||||
python3 tools/gh.py prs --file prs.txt
|
||||
cat prs.txt | python3 tools/gh.py prs --stdin
|
||||
|
||||
Prerequisites:
|
||||
- gh CLI authenticated (gh auth status)
|
||||
- Python 3.8+
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
REPO = "penpot/penpot"
|
||||
OWNER = "penpot"
|
||||
REPO_NAME = "penpot"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Shared helpers
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def run_gh(method: str, endpoint: str, **kwargs: Any) -> Any:
|
||||
"""Run a ``gh api`` call and return parsed JSON."""
|
||||
cmd = ["gh", "api", endpoint, "--method", method]
|
||||
for key, val in kwargs.items():
|
||||
if val is not None:
|
||||
cmd.extend(["-f", f"{key}={val}"])
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(f"gh error: {result.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def run_gh_graphql(query: str, variables: dict) -> Any:
|
||||
"""Run a GraphQL query via ``gh api graphql --input -``."""
|
||||
payload = json.dumps({"query": query, "variables": variables})
|
||||
cmd = ["gh", "api", "graphql", "--input", "-"]
|
||||
result = subprocess.run(cmd, input=payload, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(f"gh error: {result.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
body = json.loads(result.stdout)
|
||||
if "errors" in body:
|
||||
for err in body["errors"]:
|
||||
print(f"GraphQL error: {err.get('message')}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return body["data"]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Subcommand: issues
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
GQL_ISSUES_QUERY = """\
|
||||
query($owner: String!, $repo: String!, $milestone: Int!, $cursor: String) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
milestone(number: $milestone) {
|
||||
issues(first: 100, after: $cursor, states: __STATES__) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
... on Issue {
|
||||
number
|
||||
title
|
||||
state
|
||||
labels(first: 20) { nodes { name } }
|
||||
closedByPullRequestsReferences(first: 5) { nodes { number } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def find_milestone(title: str) -> dict:
|
||||
"""Look up milestone by title, return {number, title, open_issues, closed_issues}."""
|
||||
data = run_gh("GET", f"repos/{OWNER}/{REPO_NAME}/milestones?per_page=100&state=all")
|
||||
for ms in data:
|
||||
if ms["title"] == title:
|
||||
return {
|
||||
"number": ms["number"],
|
||||
"title": ms["title"],
|
||||
"open_issues": ms["open_issues"],
|
||||
"closed_issues": ms["closed_issues"],
|
||||
}
|
||||
print(f"ERROR: Milestone \"{title}\" not found in {REPO}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def fetch_milestone_issues(milestone_num: int, states: str) -> list[dict]:
|
||||
"""
|
||||
Fetch all issues in a milestone via paginated GraphQL.
|
||||
|
||||
Args:
|
||||
milestone_num: milestone number
|
||||
states: GraphQL states enum array literal, e.g. ``"[CLOSED]"`` or ``"[OPEN CLOSED]"``
|
||||
|
||||
Returns:
|
||||
List of {number, title, state, labels: [str], closing_prs: [int]}
|
||||
"""
|
||||
query = GQL_ISSUES_QUERY.replace("__STATES__", states)
|
||||
all_nodes: list[dict] = []
|
||||
cursor: str | None = None
|
||||
|
||||
while True:
|
||||
variables: dict[str, Any] = {
|
||||
"owner": OWNER,
|
||||
"repo": REPO_NAME,
|
||||
"milestone": milestone_num,
|
||||
"cursor": cursor,
|
||||
}
|
||||
data = run_gh_graphql(query, variables)
|
||||
issues = data["repository"]["milestone"]["issues"]
|
||||
page_info = issues["pageInfo"]
|
||||
|
||||
for node in issues["nodes"]:
|
||||
if node is None:
|
||||
continue
|
||||
all_nodes.append({
|
||||
"number": node["number"],
|
||||
"title": node["title"],
|
||||
"state": node["state"],
|
||||
"labels": [lbl["name"] for lbl in node["labels"]["nodes"]],
|
||||
"closing_prs": [pr["number"] for pr in node["closedByPullRequestsReferences"]["nodes"]],
|
||||
})
|
||||
|
||||
total = len(all_nodes)
|
||||
print(f" ... fetched {total} issues so far", file=sys.stderr)
|
||||
|
||||
if not page_info["hasNextPage"]:
|
||||
break
|
||||
cursor = page_info["endCursor"]
|
||||
|
||||
return all_nodes
|
||||
|
||||
|
||||
def load_existing_issue_numbers(filepath: str) -> set[int]:
|
||||
"""Parse all ``#NNNN`` references from a file (e.g. CHANGES.md)."""
|
||||
pattern = re.compile(r"#(\d{3,5})\b")
|
||||
nums: set[int] = set()
|
||||
with open(filepath) as f:
|
||||
for line in f:
|
||||
for m in pattern.finditer(line):
|
||||
nums.add(int(m.group(1)))
|
||||
return nums
|
||||
|
||||
|
||||
def cmd_issues(args: argparse.Namespace) -> None:
|
||||
"""Handle the ``issues`` subcommand."""
|
||||
|
||||
# Resolve milestone
|
||||
print(f"Looking up milestone \"{args.milestone}\"...", file=sys.stderr)
|
||||
ms = find_milestone(args.milestone)
|
||||
print(f"Milestone #{ms['number']}: {ms['open_issues']} open, {ms['closed_issues']} closed",
|
||||
file=sys.stderr)
|
||||
|
||||
# Map state to GraphQL enum array literal
|
||||
state_map = {"open": "[OPEN]", "closed": "[CLOSED]", "all": "[OPEN CLOSED]"}
|
||||
gql_states = state_map[args.state]
|
||||
|
||||
# Fetch issues
|
||||
print(f"Fetching {args.state} issues via GraphQL...", file=sys.stderr)
|
||||
issues = fetch_milestone_issues(ms["number"], gql_states)
|
||||
print(f"Fetched {len(issues)} issues total", file=sys.stderr)
|
||||
|
||||
# Filter by excluded labels
|
||||
if args.exclude:
|
||||
exclusions = set(label.strip() for label in args.exclude.split(","))
|
||||
filtered = [issue for issue in issues
|
||||
if not any(lbl in exclusions for lbl in issue["labels"])]
|
||||
print(f"After excluding labels: {len(filtered)} issues", file=sys.stderr)
|
||||
issues = filtered
|
||||
|
||||
# Filter to issues NOT yet in the comparison file (if --compare given)
|
||||
if args.compare:
|
||||
existing_nums = load_existing_issue_numbers(args.compare)
|
||||
missing = [iss for iss in issues if iss["number"] not in existing_nums]
|
||||
missing.sort(key=lambda x: x["number"])
|
||||
print(f"Issues not yet in changelog: {len(missing)}", file=sys.stderr)
|
||||
issues = missing
|
||||
|
||||
print(json.dumps(issues, indent=2))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Subcommand: prs
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
PRS_BATCH_SIZE = 50
|
||||
|
||||
GQL_PRS_QUERY_ITEM = """\
|
||||
pr_{num}: pullRequest(number: {num}) {{
|
||||
number
|
||||
title
|
||||
body
|
||||
state
|
||||
mergedAt
|
||||
createdAt
|
||||
author {{ login }}
|
||||
labels(first: 20) {{ nodes {{ name }} }}
|
||||
closingIssuesReferences(first: 5) {{ nodes {{ number }} }}
|
||||
}}
|
||||
"""
|
||||
|
||||
GQL_PRS_QUERY_WRAPPER = """\
|
||||
query($owner: String!, $repo: String!) {{
|
||||
repository(owner: $owner, name: $repo) {{
|
||||
{items}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def fetch_prs_batch(pr_numbers: list[int]) -> list[dict]:
|
||||
"""
|
||||
Fetch details for a list of PR numbers in a single GraphQL query.
|
||||
|
||||
Uses numbered aliases (pr_1234, pr_5678, …) so each PR is looked up by
|
||||
number in one round-trip. Returns entries in the same order as the input.
|
||||
"""
|
||||
items = "\n".join(
|
||||
GQL_PRS_QUERY_ITEM.format(num=n) for n in pr_numbers
|
||||
)
|
||||
query = GQL_PRS_QUERY_WRAPPER.format(items=items)
|
||||
variables = {"owner": OWNER, "repo": REPO_NAME}
|
||||
|
||||
data = run_gh_graphql(query, variables)
|
||||
repo = data["repository"]
|
||||
|
||||
results: list[dict] = []
|
||||
for num in pr_numbers:
|
||||
pr = repo.get(f"pr_{num}")
|
||||
if pr is None:
|
||||
results.append({
|
||||
"number": num,
|
||||
"error": "not_found",
|
||||
})
|
||||
continue
|
||||
results.append({
|
||||
"number": pr["number"],
|
||||
"title": pr["title"],
|
||||
"body": pr.get("body"),
|
||||
"state": pr["state"],
|
||||
"merged_at": pr.get("mergedAt"),
|
||||
"created_at": pr.get("createdAt"),
|
||||
"author": pr["author"]["login"] if pr["author"] else None,
|
||||
"labels": [lbl["name"] for lbl in pr["labels"]["nodes"]],
|
||||
"closing_issues": [iss["number"] for iss in pr["closingIssuesReferences"]["nodes"]],
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def cmd_prs(args: argparse.Namespace) -> None:
|
||||
"""Handle the ``prs`` subcommand."""
|
||||
|
||||
# Collect PR numbers from args / file / stdin
|
||||
pr_numbers: list[int] = []
|
||||
|
||||
if args.numbers:
|
||||
pr_numbers.extend(args.numbers)
|
||||
|
||||
if args.file:
|
||||
with open(args.file) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
pr_numbers.append(int(line))
|
||||
|
||||
if args.stdin:
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if line:
|
||||
pr_numbers.append(int(line))
|
||||
|
||||
if not pr_numbers:
|
||||
print("ERROR: no PR numbers provided (pass numbers, --file, or --stdin)",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Deduplicate while preserving order
|
||||
seen: set[int] = set()
|
||||
pr_numbers = [n for n in pr_numbers if not (n in seen or seen.add(n))]
|
||||
|
||||
print(f"Fetching {len(pr_numbers)} PRs in batches of {PRS_BATCH_SIZE}...",
|
||||
file=sys.stderr)
|
||||
|
||||
all_results: list[dict] = []
|
||||
for i in range(0, len(pr_numbers), PRS_BATCH_SIZE):
|
||||
batch = pr_numbers[i : i + PRS_BATCH_SIZE]
|
||||
print(f" batch {i // PRS_BATCH_SIZE + 1}: PRs {batch[0]}..{batch[-1]}",
|
||||
file=sys.stderr)
|
||||
all_results.extend(fetch_prs_batch(batch))
|
||||
|
||||
print(json.dumps(all_results, indent=2))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# CLI entrypoint
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Multi-purpose CLI helper for penpot/penpot GitHub operations"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True, title="subcommands")
|
||||
|
||||
# --- issues ---
|
||||
p_issues = sub.add_parser("issues", help="List issues in a milestone")
|
||||
p_issues.add_argument("milestone", help="Milestone title, e.g. '2.16.0'")
|
||||
p_issues.add_argument(
|
||||
"--state", choices=["open", "closed", "all"], default="closed",
|
||||
help="Issue state filter (default: closed)"
|
||||
)
|
||||
p_issues.add_argument(
|
||||
"--exclude", "--exclude-labels",
|
||||
help="Comma-separated labels to exclude, e.g. 'release blocker,no changelog'"
|
||||
)
|
||||
p_issues.add_argument(
|
||||
"--compare",
|
||||
help="Path to CHANGES.md; only show issues NOT yet referenced in that file"
|
||||
)
|
||||
p_issues.set_defaults(func=cmd_issues)
|
||||
|
||||
# --- prs ---
|
||||
p_prs = sub.add_parser("prs", help="Fetch details for one or more PRs")
|
||||
p_prs.add_argument(
|
||||
"numbers", type=int, nargs="*",
|
||||
help="PR numbers to fetch (space-separated)"
|
||||
)
|
||||
p_prs.add_argument(
|
||||
"--file", type=str,
|
||||
help="File with one PR number per line"
|
||||
)
|
||||
p_prs.add_argument(
|
||||
"--stdin", action="store_true",
|
||||
help="Read PR numbers from stdin (one per line)"
|
||||
)
|
||||
p_prs.set_defaults(func=cmd_prs)
|
||||
|
||||
args = parser.parse_args()
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
x
Reference in New Issue
Block a user