From 0b654311370f7d6e736c2071ed4cc9eb4abc9f78 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 14 May 2026 17:47:07 +0200 Subject: [PATCH] :paperclip: Add taiga skill and script for opencode Allows easy extraction of information from taiga urls --- .opencode/skills/taiga/SKILL.md | 110 ++++++++++++++ tools/taiga.py | 261 ++++++++++++++++++++++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 .opencode/skills/taiga/SKILL.md create mode 100755 tools/taiga.py diff --git a/.opencode/skills/taiga/SKILL.md b/.opencode/skills/taiga/SKILL.md new file mode 100644 index 0000000000..890c965c03 --- /dev/null +++ b/.opencode/skills/taiga/SKILL.md @@ -0,0 +1,110 @@ +--- +name: taiga +description: Fetch information from Taiga public API for the Penpot project (id 345963) — issues, user stories, and tasks, without authentication. +metadata: {"clawdbot":{"requires":{"bins":["python3"]}}} +--- + +# Taiga API Skill + +Fetch information from Taiga public API for the **Penpot** project +(project id: `345963`, slug: `penpot`). + +**No authentication required** — only public project data is accessed. + +## Prerequisites + +- `python3` — the `tools/taiga.py` CLI script is self-contained (stdlib only) + +## Quick Start + +The easiest way is to use the bundled Python script: + +```bash +# Pass a Taiga URL directly +python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714 + +# Or use " " syntax +python3 tools/taiga.py us 14128 +python3 tools/taiga.py task 13648 + +# Add --json for raw output +python3 tools/taiga.py --json issue 13714 + +# See full usage +python3 tools/taiga.py --help +``` + +## URL Pattern Reference + +Taiga web URLs follow these patterns: + +| Type | Web URL Pattern | +|------|----------------| +| Issue | `https://tree.taiga.io/project/penpot/issue/` | +| User Story | `https://tree.taiga.io/project/penpot/us/` | +| Task | `https://tree.taiga.io/project/penpot/task/` | + +To extract the **type** and **ref** from a URL: +- `issue/13714` → type=`issue`, ref=`13714` +- `us/14128` → type=`us`, ref=`14128` +- `task/13648` → type=`task`, ref=`13648` + +## Python Script Reference + +The `tools/taiga.py` script wraps the Taiga API into a single convenient CLI +with sensible defaults. + +### Usage + +``` +python3 tools/taiga.py +python3 tools/taiga.py +python3 tools/taiga.py [--json] +python3 tools/taiga.py [--json] +``` + +### Examples + +```bash +# By URL (recommended — no need to think about type/ref) +python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714 + +# By type and ref +python3 tools/taiga.py us 14128 +python3 tools/taiga.py task 13648 + +# Raw JSON output +python3 tools/taiga.py --json issue 13714 +``` + +### Output + +The script prints a clean, structured summary: + +```text +User Story #11964 — 🔴 [DESIGN TOKENS] Typography Composite Input +================================ +Status: Defining +Milestone: design-systems-sprint-26 +Points: 3 role(s) +Assignee: Natacha Menjibar +Author: Natacha Menjibar +Created: 2025-09-01 +Tags: iop-design-tokens +URL: https://tree.taiga.io/project/penpot/us/11964 +================================ + +``` + +The fields section includes type-specific information: +- **Issues:** Status, Type ID, Severity ID, Priority ID +- **User Stories:** Status, Milestone, Points +- **Tasks:** Status, Milestone, Parent US + +## Reference + +- API docs: https://docs.taiga.io/api.html +- Taiga instance: https://tree.taiga.io +- API base: https://api.taiga.io/api/v1 +- Penpot project id: `345963` +- Penpot project slug: `penpot` 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()