mirror of
https://github.com/penpot/penpot.git
synced 2026-05-30 20:28:07 +00:00
✨ Add minor usability improvements to update-changelog skill
This commit is contained in:
parent
50ec6ad777
commit
bee1a89698
@ -36,13 +36,13 @@ Use the helper script. It uses GraphQL for efficient single-pass fetching
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# All closed issues (default)
|
# All closed issues (default)
|
||||||
python3 tools/gh.py issues "2.16.0"
|
python3 tools/gh.py issues --milestone "2.16.0"
|
||||||
|
|
||||||
# Include open issues too
|
# Include open issues too
|
||||||
python3 tools/gh.py issues "2.16.0" --state all
|
python3 tools/gh.py issues --milestone "2.16.0" --state all
|
||||||
|
|
||||||
# Exclude entries that should not go in the changelog
|
# Exclude entries that should not go in the changelog
|
||||||
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
|
python3 tools/gh.py issues --milestone "2.16.0" --exclude "release blocker,no changelog"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Exclusion rules (issue-level):**
|
**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:
|
are NOT yet referenced in the changelog:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" --compare CHANGES.md
|
python3 tools/gh.py issues --milestone "2.16.0" --exclude "release blocker,no changelog" --compare CHANGES.md
|
||||||
```
|
```
|
||||||
|
|
||||||
This returns a filtered JSON array with only the missing issues.
|
This returns a filtered JSON array with only the missing issues.
|
||||||
@ -102,11 +102,50 @@ python3 tools/gh.py prs --milestone "2.16.0" --state all
|
|||||||
```
|
```
|
||||||
|
|
||||||
The `prs` command returns JSON with `number`, `title`, `body`, `state`,
|
The `prs` command returns JSON with `number`, `title`, `body`, `state`,
|
||||||
`merged_at`, `author`, `labels`, and `closing_issues`. PRs are fetched in
|
`merged_at`, `milestone`, `author`, `labels`, and `closing_issues`. PRs are
|
||||||
batches of 50 via GraphQL to stay within API limits (milestone mode uses
|
fetched in batches of 50 via GraphQL to stay within API limits (milestone mode
|
||||||
paginated GraphQL on the milestone's `pullRequests` connection).
|
uses paginated GraphQL on the milestone's `pullRequests` connection).
|
||||||
|
|
||||||
### 5. Categorize entries — strictly by issue type, never by labels or emoji
|
> **⚠️ 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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# One or more issue numbers
|
||||||
|
python3 tools/gh.py issues 9010 9899 9900
|
||||||
|
|
||||||
|
# From a file
|
||||||
|
python3 tools/gh.py issues --file issues.txt
|
||||||
|
|
||||||
|
# From stdin
|
||||||
|
cat issues.txt | python3 tools/gh.py issues --stdin
|
||||||
|
```
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
Use the **Issue Type** field (GitHub's native issue type, exposed as
|
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
|
`issue_type` in the `gh.py` JSON output) to determine which section an entry
|
||||||
@ -163,7 +202,7 @@ tracked in the milestone.
|
|||||||
> skip it. PR [#3](https://github.com/penpot/penpot/pull/3) (ancient License PR
|
> 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.
|
> claiming to close a plugin API issue) is a known example.
|
||||||
|
|
||||||
### 5a. ⚠️ Verify PR merge status before writing
|
### 7. ⚠️ Verify PR merge status before writing
|
||||||
|
|
||||||
A closed issue may list closing PRs that were **closed without merging**
|
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
|
(e.g., a community PR that was superseded by another). The changelog must
|
||||||
@ -187,7 +226,7 @@ superseded it:
|
|||||||
|
|
||||||
Replace the reference in the changelog entry with the correct merged PR number.
|
Replace the reference in the changelog entry with the correct merged PR number.
|
||||||
|
|
||||||
### 6. Read the current CHANGES.md
|
### 8. Read the current CHANGES.md
|
||||||
|
|
||||||
Read the top of `CHANGES.md` to understand the existing format and find the
|
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`
|
insertion point (newest version goes at the top, after the `# CHANGELOG`
|
||||||
@ -222,7 +261,7 @@ Format details:
|
|||||||
- When an entry already exists in an earlier version section, it must be removed
|
- When an entry already exists in an earlier version section, it must be removed
|
||||||
from the current version to avoid duplicates
|
from the current version to avoid duplicates
|
||||||
|
|
||||||
### 7. Build the description text
|
### 9. Build the description text
|
||||||
|
|
||||||
Derive the description from the issue title, not the PR title. Strip leading
|
Derive the description from the issue title, not the PR title. Strip leading
|
||||||
emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`) and focus on the
|
emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`) and focus on the
|
||||||
@ -236,13 +275,13 @@ Examples:
|
|||||||
| `Comment content is not sanitized before rendering, enabling stored XSS` | `Sanitize comment content on rendering` |
|
| `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` |
|
| `Custom uploaded font family names are not sanitized` | `Sanitize font family names on custom uploaded fonts` |
|
||||||
|
|
||||||
### 8. Insert the section into CHANGES.md
|
### 10. Insert the section into CHANGES.md
|
||||||
|
|
||||||
Insert the new version section right after the `# CHANGELOG` header (before
|
Insert the new version section right after the `# CHANGELOG` header (before
|
||||||
the previous version entry). Use the `edit` tool with enough context to make
|
the previous version entry). Use the `edit` tool with enough context to make
|
||||||
a unique match.
|
a unique match.
|
||||||
|
|
||||||
### 9. Verify
|
### 11. Verify
|
||||||
|
|
||||||
Read the top of `CHANGES.md` and confirm:
|
Read the top of `CHANGES.md` and confirm:
|
||||||
- The version header is correct
|
- The version header is correct
|
||||||
@ -251,7 +290,7 @@ Read the top of `CHANGES.md` and confirm:
|
|||||||
- The section ordering is correct (newest first)
|
- The section ordering is correct (newest first)
|
||||||
- Formatting matches the surrounding entries
|
- Formatting matches the surrounding entries
|
||||||
|
|
||||||
### 10. Cross-reference milestone PRs against the changelog
|
### 12. Cross-reference milestone PRs against the changelog
|
||||||
|
|
||||||
Issues can be fixed by PRs that aren't in the milestone, and merged PRs in
|
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
|
the milestone may not close any tracked issue. After writing, run a full
|
||||||
|
|||||||
193
tools/gh.py
193
tools/gh.py
@ -174,41 +174,144 @@ def load_existing_issue_numbers(filepath: str) -> set[int]:
|
|||||||
return nums
|
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:
|
def cmd_issues(args: argparse.Namespace) -> None:
|
||||||
"""Handle the ``issues`` subcommand."""
|
"""Handle the ``issues`` subcommand."""
|
||||||
|
|
||||||
# Resolve milestone
|
# ── Milestone mode: fetch all issues in a milestone ──
|
||||||
print(f"Looking up milestone \"{args.milestone}\"...", file=sys.stderr)
|
if args.milestone:
|
||||||
ms = find_milestone(args.milestone)
|
print(f"Looking up milestone \"{args.milestone}\"...", file=sys.stderr)
|
||||||
print(f"Milestone #{ms['number']}: {ms['open_issues']} open, {ms['closed_issues']} closed",
|
ms = find_milestone(args.milestone)
|
||||||
|
print(f"Milestone #{ms['number']}: {ms['open_issues']} open, {ms['closed_issues']} closed",
|
||||||
|
file=sys.stderr)
|
||||||
|
|
||||||
|
state_map = {"open": "[OPEN]", "closed": "[CLOSED]", "all": "[OPEN CLOSED]"}
|
||||||
|
gql_states = state_map[args.state]
|
||||||
|
|
||||||
|
print(f"Fetching {args.state} issues via GraphQL...", file=sys.stderr)
|
||||||
|
issues = fetch_milestone_issues(ms["number"], gql_states)
|
||||||
|
print(f"Fetched {len(issues)} issues total", file=sys.stderr)
|
||||||
|
|
||||||
|
# Filter by excluded labels
|
||||||
|
if args.exclude:
|
||||||
|
exclusions = set(label.strip() for label in args.exclude.split(","))
|
||||||
|
filtered = [issue for issue in issues
|
||||||
|
if not any(lbl in exclusions for lbl in issue["labels"])]
|
||||||
|
print(f"After excluding labels: {len(filtered)} issues", file=sys.stderr)
|
||||||
|
issues = filtered
|
||||||
|
|
||||||
|
# Filter to issues NOT yet in the comparison file (if --compare given)
|
||||||
|
if args.compare:
|
||||||
|
existing_nums = load_existing_issue_numbers(args.compare)
|
||||||
|
missing = [iss for iss in issues if iss["number"] not in existing_nums]
|
||||||
|
missing.sort(key=lambda x: x["number"])
|
||||||
|
print(f"Issues not yet in changelog: {len(missing)}", file=sys.stderr)
|
||||||
|
issues = missing
|
||||||
|
|
||||||
|
print(json.dumps(issues, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Batch mode: fetch specific issues by number ──
|
||||||
|
issue_numbers: list[int] = []
|
||||||
|
|
||||||
|
if args.numbers:
|
||||||
|
issue_numbers.extend(args.numbers)
|
||||||
|
|
||||||
|
if args.file:
|
||||||
|
with open(args.file) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
issue_numbers.append(int(line))
|
||||||
|
|
||||||
|
if args.stdin:
|
||||||
|
for line in sys.stdin:
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
issue_numbers.append(int(line))
|
||||||
|
|
||||||
|
if not issue_numbers:
|
||||||
|
print("ERROR: no issue numbers provided (pass numbers, --file, --stdin, or --milestone)",
|
||||||
|
file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Deduplicate while preserving order
|
||||||
|
seen: set[int] = set()
|
||||||
|
issue_numbers = [n for n in issue_numbers if not (n in seen or seen.add(n))]
|
||||||
|
|
||||||
|
print(f"Fetching {len(issue_numbers)} issues in batches of {ISSUES_BATCH_SIZE}...",
|
||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
|
|
||||||
# Map state to GraphQL enum array literal
|
all_results: list[dict] = []
|
||||||
state_map = {"open": "[OPEN]", "closed": "[CLOSED]", "all": "[OPEN CLOSED]"}
|
for i in range(0, len(issue_numbers), ISSUES_BATCH_SIZE):
|
||||||
gql_states = state_map[args.state]
|
batch = issue_numbers[i : i + ISSUES_BATCH_SIZE]
|
||||||
|
print(f" batch {i // ISSUES_BATCH_SIZE + 1}: issues {batch[0]}..{batch[-1]}",
|
||||||
|
file=sys.stderr)
|
||||||
|
all_results.extend(fetch_issues_batch(batch))
|
||||||
|
|
||||||
# Fetch issues
|
print(json.dumps(all_results, indent=2))
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
@ -228,6 +331,8 @@ query($owner: String!, $repo: String!, $milestone: Int!, $cursor: String) {
|
|||||||
title
|
title
|
||||||
state
|
state
|
||||||
mergedAt
|
mergedAt
|
||||||
|
milestone { title }
|
||||||
|
baseRefName
|
||||||
author { login }
|
author { login }
|
||||||
labels(first: 20) { nodes { name } }
|
labels(first: 20) { nodes { name } }
|
||||||
closingIssuesReferences(first: 5) { nodes { number } }
|
closingIssuesReferences(first: 5) { nodes { number } }
|
||||||
@ -275,6 +380,8 @@ def fetch_milestone_prs(milestone_num: int, states: str) -> list[dict]:
|
|||||||
"title": node["title"],
|
"title": node["title"],
|
||||||
"state": node["state"],
|
"state": node["state"],
|
||||||
"merged_at": node.get("mergedAt"),
|
"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,
|
"author": node["author"]["login"] if node["author"] else None,
|
||||||
"labels": [lbl["name"] for lbl in node["labels"]["nodes"]],
|
"labels": [lbl["name"] for lbl in node["labels"]["nodes"]],
|
||||||
"closing_issues": [iss["number"] for iss in node["closingIssuesReferences"]["nodes"]],
|
"closing_issues": [iss["number"] for iss in node["closingIssuesReferences"]["nodes"]],
|
||||||
@ -304,6 +411,8 @@ GQL_PRS_QUERY_ITEM = """\
|
|||||||
state
|
state
|
||||||
mergedAt
|
mergedAt
|
||||||
createdAt
|
createdAt
|
||||||
|
milestone {{ title }}
|
||||||
|
baseRefName
|
||||||
author {{ login }}
|
author {{ login }}
|
||||||
labels(first: 20) {{ nodes {{ name }} }}
|
labels(first: 20) {{ nodes {{ name }} }}
|
||||||
closingIssuesReferences(first: 5) {{ nodes {{ number }} }}
|
closingIssuesReferences(first: 5) {{ nodes {{ number }} }}
|
||||||
@ -351,6 +460,8 @@ def fetch_prs_batch(pr_numbers: list[int]) -> list[dict]:
|
|||||||
"state": pr["state"],
|
"state": pr["state"],
|
||||||
"merged_at": pr.get("mergedAt"),
|
"merged_at": pr.get("mergedAt"),
|
||||||
"created_at": pr.get("createdAt"),
|
"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,
|
"author": pr["author"]["login"] if pr["author"] else None,
|
||||||
"labels": [lbl["name"] for lbl in pr["labels"]["nodes"]],
|
"labels": [lbl["name"] for lbl in pr["labels"]["nodes"]],
|
||||||
"closing_issues": [iss["number"] for iss in pr["closingIssuesReferences"]["nodes"]],
|
"closing_issues": [iss["number"] for iss in pr["closingIssuesReferences"]["nodes"]],
|
||||||
@ -431,11 +542,21 @@ def main() -> None:
|
|||||||
sub = parser.add_subparsers(dest="command", required=True, title="subcommands")
|
sub = parser.add_subparsers(dest="command", required=True, title="subcommands")
|
||||||
|
|
||||||
# --- issues ---
|
# --- issues ---
|
||||||
p_issues = sub.add_parser("issues", help="List issues in a milestone")
|
p_issues = sub.add_parser(
|
||||||
p_issues.add_argument("milestone", help="Milestone title, e.g. '2.16.0'")
|
"issues",
|
||||||
|
help="Fetch issues by number (batch) or list issues in a milestone"
|
||||||
|
)
|
||||||
|
p_issues.add_argument(
|
||||||
|
"numbers", type=int, nargs="*",
|
||||||
|
help="Issue numbers to fetch (space-separated, batch mode)"
|
||||||
|
)
|
||||||
|
p_issues.add_argument(
|
||||||
|
"--milestone", type=str,
|
||||||
|
help="Milestone title to list all issues from (e.g. '2.16.0')"
|
||||||
|
)
|
||||||
p_issues.add_argument(
|
p_issues.add_argument(
|
||||||
"--state", choices=["open", "closed", "all"], default="closed",
|
"--state", choices=["open", "closed", "all"], default="closed",
|
||||||
help="Issue state filter (default: closed)"
|
help="Issue state filter when using --milestone (default: closed)"
|
||||||
)
|
)
|
||||||
p_issues.add_argument(
|
p_issues.add_argument(
|
||||||
"--exclude", "--exclude-labels",
|
"--exclude", "--exclude-labels",
|
||||||
@ -445,6 +566,14 @@ def main() -> None:
|
|||||||
"--compare",
|
"--compare",
|
||||||
help="Path to CHANGES.md; only show issues NOT yet referenced in that file"
|
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)
|
p_issues.set_defaults(func=cmd_issues)
|
||||||
|
|
||||||
# --- prs ---
|
# --- prs ---
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user