diff --git a/.gitignore b/.gitignore index 8586839ba0..9d64190075 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,4 @@ /**/node_modules /**/.yarn/* /.pnpm-store +/tools/__pycache__ diff --git a/.opencode/skills/update-changelog/SKILL.md b/.opencode/skills/update-changelog/SKILL.md index 5752b65aa3..29c3d295b4 100644 --- a/.opencode/skills/update-changelog/SKILL.md +++ b/.opencode/skills/update-changelog/SKILL.md @@ -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 @)` @@ -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, diff --git a/tools/gh.py b/tools/gh.py index 2aa7ac4da8..afd81da619 100755 --- a/tools/gh.py +++ b/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"]], }) diff --git a/tools/taiga.py b/tools/taiga.py new file mode 100755 index 0000000000..1e96364a31 --- /dev/null +++ b/tools/taiga.py @@ -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 + python3 tools/taiga.py + python3 tools/taiga.py [--json] + python3 tools/taiga.py [--json] + +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 " " (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//', + 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()