mirror of
https://github.com/penpot/penpot.git
synced 2026-05-19 14:14:05 -04:00
📎 Backport opencode skills from staging
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -84,3 +84,4 @@
|
||||
/**/node_modules
|
||||
/**/.yarn/*
|
||||
/.pnpm-store
|
||||
/tools/__pycache__
|
||||
|
||||
@@ -45,12 +45,12 @@ python3 tools/gh.py issues "2.16.0" --state all
|
||||
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
|
||||
```
|
||||
|
||||
**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
|
||||
**Exclusion rules:**
|
||||
- `no changelog` label — Chore/refactor work that doesn't need a changelog entry
|
||||
- `Task` issue type — Internal chores are not user-facing; filter these out after fetching
|
||||
|
||||
The script outputs JSON with each entry containing `number`, `title`, `state`,
|
||||
`labels`, and `closing_prs` (the PRs that fix each issue).
|
||||
`issue_type`, `labels`, and `closing_prs` (the PRs that fix each issue).
|
||||
|
||||
### 3. Identify missing entries (optional)
|
||||
|
||||
@@ -84,15 +84,27 @@ 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.
|
||||
|
||||
### 5. Categorize entries
|
||||
### 5. Categorize entries — strictly by issue type, never by labels or emoji
|
||||
|
||||
Check the labels on each issue to determine which section it belongs to:
|
||||
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
|
||||
belongs to.
|
||||
|
||||
| Label / Title prefix | Changelog section |
|
||||
|----------------------|-------------------|
|
||||
| `bug` label or `:bug:` title prefix | `### :bug: Bugs fixed` |
|
||||
| `enhancement` label or `:sparkles:` prefix | `### :sparkles: New features & Enhancements` |
|
||||
| No label | Infer from title convention, default to bug fix |
|
||||
> **⚠️ CRITICAL: Never use labels or title emoji prefixes for categorization.**
|
||||
> Labels like `bug` and `enhancement`, as well as title prefixes like `:bug:`
|
||||
> and `:sparkles:`, are frequently inaccurate, missing, or contradictory to the
|
||||
> actual issue type. The `issue_type` field from `gh.py` is the single source
|
||||
> of truth.
|
||||
|
||||
| `issue_type` value | Changelog section |
|
||||
|--------------------|-------------------|
|
||||
| `Bug` | `### :bug: Bugs fixed` |
|
||||
| `Feature` or `Enhancement` | `### :sparkles: New features & Enhancements` |
|
||||
| `Task` | **Exclude** — internal chores are not user-facing |
|
||||
| `null` (not set) | Check labels as a fallback: `bug` label → bugs, otherwise enhancements |
|
||||
|
||||
The `gh.py` issues command already includes `issue_type` in every entry's
|
||||
output. **No separate GraphQL query is needed.**
|
||||
|
||||
**Community contribution attribution:** If the issue or its fix PR has the
|
||||
`community contribution` label, add an attribution `(by @<github_username>)`
|
||||
@@ -205,6 +217,7 @@ Read the top of `CHANGES.md` and confirm:
|
||||
can find the code changes.
|
||||
- **Latest version first.** New sections are inserted at the top of the
|
||||
changelog, below the `# CHANGELOG` header.
|
||||
- **Issue Type determines section — exclusively.** Use the `issue_type` field from `gh.py` output (Bug → `:bug:`, Feature/Enhancement → `:sparkles:`). **Do not** use labels (`bug`, `enhancement`) or title emoji prefixes (`:bug:`, `:sparkles:`) — they are frequently wrong or contradictory. The `issue_type` is the single source of truth.
|
||||
- **User-facing descriptions.** Write from the user's perspective — describe
|
||||
what broke and what was fixed, not internal implementation details.
|
||||
- **Community attribution.** When the issue or fix PR has the
|
||||
@@ -213,8 +226,9 @@ Read the top of `CHANGES.md` and confirm:
|
||||
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.
|
||||
- **Excluded issues.** Issues with `no changelog` label must be excluded.
|
||||
Issues with `issue_type: "Task"` must also be excluded — they are internal
|
||||
chores, not user-facing changes.
|
||||
- **Multiple PRs per issue.** If multiple PRs fix the same issue, list them
|
||||
comma-separated inline: `(PR: [#A](url), [#B](url))`.
|
||||
- **Duplicate removal.** If an entry already exists in a prior version section,
|
||||
|
||||
21
tools/gh.py
21
tools/gh.py
@@ -80,14 +80,15 @@ query($owner: String!, $repo: String!, $milestone: Int!, $cursor: String) {
|
||||
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 } }
|
||||
}
|
||||
nodes {
|
||||
... on Issue {
|
||||
number
|
||||
title
|
||||
state
|
||||
issueType { name }
|
||||
labels(first: 20) { nodes { name } }
|
||||
closedByPullRequestsReferences(first: 5) { nodes { number } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,7 +121,7 @@ def fetch_milestone_issues(milestone_num: int, states: str) -> list[dict]:
|
||||
states: GraphQL states enum array literal, e.g. ``"[CLOSED]"`` or ``"[OPEN CLOSED]"``
|
||||
|
||||
Returns:
|
||||
List of {number, title, state, labels: [str], closing_prs: [int]}
|
||||
List of {number, title, state, issue_type: str|None, labels: [str], closing_prs: [int]}
|
||||
"""
|
||||
query = GQL_ISSUES_QUERY.replace("__STATES__", states)
|
||||
all_nodes: list[dict] = []
|
||||
@@ -140,10 +141,12 @@ def fetch_milestone_issues(milestone_num: int, states: str) -> list[dict]:
|
||||
for node in issues["nodes"]:
|
||||
if node is None:
|
||||
continue
|
||||
issue_type = node.get("issueType")
|
||||
all_nodes.append({
|
||||
"number": node["number"],
|
||||
"title": node["title"],
|
||||
"state": node["state"],
|
||||
"issue_type": issue_type["name"] if issue_type else None,
|
||||
"labels": [lbl["name"] for lbl in node["labels"]["nodes"]],
|
||||
"closing_prs": [pr["number"] for pr in node["closedByPullRequestsReferences"]["nodes"]],
|
||||
})
|
||||
|
||||
261
tools/taiga.py
Executable file
261
tools/taiga.py
Executable file
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Taiga API client — fetch public issues, user stories, and tasks from the
|
||||
Penpot project (id 345963) without authentication.
|
||||
|
||||
Usage:
|
||||
python3 tools/taiga.py <taiga-url>
|
||||
python3 tools/taiga.py <type> <ref>
|
||||
python3 tools/taiga.py [--json] <taiga-url>
|
||||
python3 tools/taiga.py [--json] <type> <ref>
|
||||
|
||||
Examples:
|
||||
python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714
|
||||
python3 tools/taiga.py --json https://tree.taiga.io/project/penpot/us/14128
|
||||
python3 tools/taiga.py task 13648
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
API_BASE = "https://api.taiga.io/api/v1"
|
||||
PROJECT_ID = 345963
|
||||
|
||||
ENDPOINT_MAP = {
|
||||
"issue": "issues",
|
||||
"us": "userstories",
|
||||
"task": "tasks",
|
||||
}
|
||||
|
||||
TYPE_LABELS = {
|
||||
"issue": "Issue",
|
||||
"us": "User Story",
|
||||
"task": "Task",
|
||||
}
|
||||
|
||||
|
||||
# ── URL Parsing ──────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_taiga_url(url: str) -> tuple[str, int] | None:
|
||||
"""Extract (type, ref) from a tree.taiga.io URL.
|
||||
|
||||
Supported patterns:
|
||||
.../project/penpot/issue/13714
|
||||
.../project/penpot/us/14128
|
||||
.../project/penpot/task/13648
|
||||
"""
|
||||
m = re.search(r"/project/penpot/(issue|us|task)/(\d+)", url)
|
||||
if not m:
|
||||
return None
|
||||
return m.group(1), int(m.group(2))
|
||||
|
||||
|
||||
# ── API call ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def fetch_item(endpoint: str, ref: int) -> dict | None:
|
||||
"""Fetch a single item by ref using the 'by_ref' endpoint."""
|
||||
url = f"{API_BASE}/{endpoint}/by_ref?ref={ref}&project={PROJECT_ID}"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"Error: HTTP {e.code} — {e.reason}", file=sys.stderr)
|
||||
if e.code == 404:
|
||||
print(
|
||||
f" Item (ref={ref}) not found in project {PROJECT_ID}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
except urllib.error.URLError as e:
|
||||
print(f"Error: {e.reason}", file=sys.stderr)
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: invalid JSON response — {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
# ── Output formatting ────────────────────────────────────────────────────────
|
||||
|
||||
def _val(value, default="—"):
|
||||
return value if value is not None else default
|
||||
|
||||
|
||||
def _tag_list(tags):
|
||||
"""Pretty-print tag list. Tags are arrays of [name, color] pairs."""
|
||||
if not tags:
|
||||
return "—"
|
||||
names = [t[0] if isinstance(t, list) else str(t) for t in tags]
|
||||
return ", ".join(names)
|
||||
|
||||
|
||||
def _extra_name(extra_info):
|
||||
"""Extract a display name from an *_extra_info dict."""
|
||||
if not extra_info:
|
||||
return "—"
|
||||
return extra_info.get("full_name_display") or extra_info.get("username") or "—"
|
||||
|
||||
|
||||
def _status_name(status_info):
|
||||
"""Extract status name from status_extra_info."""
|
||||
if not status_info:
|
||||
return "—"
|
||||
return status_info.get("name", "—")
|
||||
|
||||
|
||||
def _project_name(proj_info):
|
||||
"""Extract project name from project_extra_info."""
|
||||
if not proj_info:
|
||||
return "—"
|
||||
return proj_info.get("name", "—")
|
||||
|
||||
|
||||
def _assignee(item):
|
||||
"""Return the assignee display name."""
|
||||
return _extra_name(item.get("assigned_to_extra_info"))
|
||||
|
||||
|
||||
def _owner(item):
|
||||
return _extra_name(item.get("owner_extra_info"))
|
||||
|
||||
|
||||
def format_summary(item: dict, item_type: str) -> str:
|
||||
"""Build a printable summary matching the requested format."""
|
||||
label = TYPE_LABELS.get(item_type, item_type.capitalize())
|
||||
subject = item.get("subject", "(no subject)")
|
||||
ref = item.get("ref", "?")
|
||||
status = _status_name(item.get("status_extra_info"))
|
||||
assignee = _assignee(item)
|
||||
owner = _owner(item)
|
||||
created = item.get("created_date", "")[:10] if item.get("created_date") else ""
|
||||
tags = _tag_list(item.get("tags", []))
|
||||
|
||||
# Title line
|
||||
title = f"{label} #{ref} — {subject}"
|
||||
|
||||
# Fields section (no indent)
|
||||
fields = []
|
||||
fields.append(f"Status: {status}")
|
||||
|
||||
if item_type == "us":
|
||||
milestone = item.get("milestone_slug") or ""
|
||||
points = item.get("points") or {}
|
||||
point_count = len(points)
|
||||
fields.append(f"Milestone: {milestone}")
|
||||
fields.append(f"Points: {point_count} role(s)")
|
||||
elif item_type == "task":
|
||||
milestone = item.get("milestone_slug") or ""
|
||||
parent = item.get("user_story")
|
||||
fields.append(f"Milestone: {milestone}")
|
||||
fields.append(f"Parent US: {parent if parent else '—'}")
|
||||
elif item_type == "issue":
|
||||
issue_type_id = item.get("type", "")
|
||||
severity_id = item.get("severity", "")
|
||||
priority_id = item.get("priority", "")
|
||||
fields.append(f"Type ID: {issue_type_id}")
|
||||
fields.append(f"Severity ID: {severity_id}")
|
||||
fields.append(f"Priority ID: {priority_id}")
|
||||
|
||||
fields.append(f"Assignee: {assignee}")
|
||||
fields.append(f"Author: {owner}")
|
||||
fields.append(f"Created: {created}")
|
||||
fields.append(f"Tags: {tags}")
|
||||
|
||||
url = f"https://tree.taiga.io/project/penpot/{item_type}/{ref}"
|
||||
fields.append(f"URL: {url}")
|
||||
|
||||
# Assemble output
|
||||
sep = "================================"
|
||||
parts = [title, sep]
|
||||
parts.extend(fields)
|
||||
|
||||
# Full description after second separator
|
||||
desc = item.get("description") or ""
|
||||
if desc.strip():
|
||||
parts.append(sep)
|
||||
parts.append(desc)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# ── CLI ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Fetch public items from the Penpot Taiga project.",
|
||||
epilog=(
|
||||
"Examples:\n"
|
||||
" %(prog)s https://tree.taiga.io/project/penpot/issue/13714\n"
|
||||
" %(prog)s --json us 14128\n"
|
||||
" %(prog)s task 13648"
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
dest="raw_json",
|
||||
help="Output raw JSON instead of formatted summary.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"args",
|
||||
nargs="+",
|
||||
help='Either a Taiga URL, or "<type> <ref>" (e.g. issue 13714).',
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
parser = build_parser()
|
||||
opts = parser.parse_args()
|
||||
|
||||
# Determine (type, ref) from arguments
|
||||
item_type: str | None = None
|
||||
ref: int | None = None
|
||||
|
||||
if len(opts.args) == 1:
|
||||
# Single argument — must be a Taiga URL
|
||||
parsed = parse_taiga_url(opts.args[0])
|
||||
if parsed is None:
|
||||
print(
|
||||
"Error: could not parse Taiga URL. "
|
||||
'Expected format: https://tree.taiga.io/project/penpot/<type>/<ref>',
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
item_type, ref = parsed
|
||||
elif len(opts.args) == 2:
|
||||
item_type, ref_str = opts.args
|
||||
if item_type not in ENDPOINT_MAP:
|
||||
print(
|
||||
f"Error: unknown type '{item_type}'. "
|
||||
f"Expected one of: {', '.join(ENDPOINT_MAP)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
try:
|
||||
ref = int(ref_str)
|
||||
except ValueError:
|
||||
print(f"Error: ref must be a number, got '{ref_str}'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
endpoint = ENDPOINT_MAP[item_type]
|
||||
item = fetch_item(endpoint, ref)
|
||||
|
||||
if item is None:
|
||||
sys.exit(1)
|
||||
|
||||
if opts.raw_json:
|
||||
print(json.dumps(item, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(format_summary(item, item_type))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user