From 68c112d2c8b7c763cb06f1d580df6f6719f0c9e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabor=20K=C3=B6rber?= Date: Tue, 19 May 2026 19:52:02 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20gitea=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports dirigent's gitea scripts into a self-contained reliquary plugin. Scripts run via uv with PEP 723 inline deps — no project venv needed. Gitea connection inferred from `git remote get-url origin` with env-var overrides; only GITEA_TOKEN is mandatory. Workpad path and tickets directory are parameterized so the plugin works in any project. Label set externalized to labels.default.json with project-level override via .gitea-labels.json. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/gitea/.claude-plugin/plugin.json | 9 + plugins/gitea/README.md | 63 +++++++ plugins/gitea/skills/gitea/SKILL.md | 133 ++++++++++++++ .../gitea/skills/gitea/labels.default.json | 9 + plugins/gitea/skills/gitea/scripts/client.py | 172 ++++++++++++++++++ .../skills/gitea/scripts/close_ticket.py | 42 +++++ plugins/gitea/skills/gitea/scripts/config.py | 117 ++++++++++++ .../skills/gitea/scripts/create_ticket.py | 93 ++++++++++ .../skills/gitea/scripts/fetch_ticket.py | 109 +++++++++++ .../skills/gitea/scripts/list_tickets.py | 52 ++++++ plugins/gitea/skills/gitea/scripts/models.py | 66 +++++++ plugins/gitea/skills/gitea/scripts/ping.py | 33 ++++ .../skills/gitea/scripts/post_comment.py | 61 +++++++ .../skills/gitea/scripts/setup_labels.py | 64 +++++++ .../skills/gitea/scripts/start_ticket.py | 131 +++++++++++++ 15 files changed, 1154 insertions(+) create mode 100644 plugins/gitea/.claude-plugin/plugin.json create mode 100644 plugins/gitea/README.md create mode 100644 plugins/gitea/skills/gitea/SKILL.md create mode 100644 plugins/gitea/skills/gitea/labels.default.json create mode 100644 plugins/gitea/skills/gitea/scripts/client.py create mode 100644 plugins/gitea/skills/gitea/scripts/close_ticket.py create mode 100644 plugins/gitea/skills/gitea/scripts/config.py create mode 100644 plugins/gitea/skills/gitea/scripts/create_ticket.py create mode 100644 plugins/gitea/skills/gitea/scripts/fetch_ticket.py create mode 100644 plugins/gitea/skills/gitea/scripts/list_tickets.py create mode 100644 plugins/gitea/skills/gitea/scripts/models.py create mode 100644 plugins/gitea/skills/gitea/scripts/ping.py create mode 100644 plugins/gitea/skills/gitea/scripts/post_comment.py create mode 100644 plugins/gitea/skills/gitea/scripts/setup_labels.py create mode 100644 plugins/gitea/skills/gitea/scripts/start_ticket.py diff --git a/plugins/gitea/.claude-plugin/plugin.json b/plugins/gitea/.claude-plugin/plugin.json new file mode 100644 index 0000000..e101812 --- /dev/null +++ b/plugins/gitea/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "gitea", + "version": "0.1.0", + "description": "Gitea issue integration: fetch/create/list/close tickets, post comments, scaffold labels. Workpad-aware.", + "author": { + "name": "Gabor Körber", + "email": "gab@g4b.org" + } +} diff --git a/plugins/gitea/README.md b/plugins/gitea/README.md new file mode 100644 index 0000000..42b53a4 --- /dev/null +++ b/plugins/gitea/README.md @@ -0,0 +1,63 @@ +# gitea plugin + +Claude Code plugin for working with Gitea issues from any project. Ships a single skill (`gitea`) and a set of self-contained PEP 723 scripts that need no project-level Python setup — `uv run` builds an isolated env per script on first invocation. + +## What it can do + +| Task | Script | +|------|--------| +| Verify connectivity | `ping.py` | +| Fetch a ticket as markdown | `fetch_ticket.py` | +| Create an issue (inline or from a goal file) | `create_ticket.py` | +| Post a markdown file as a comment | `post_comment.py` | +| Start work on a ticket (branch + context) | `start_ticket.py` | +| List/search issues | `list_tickets.py` | +| Close issues | `close_ticket.py` | +| Scaffold standard labels in a new repo | `setup_labels.py` | + +## Prerequisites + +- [uv](https://docs.astral.sh/uv/) on PATH. +- A Gitea instance with API access and an API token. +- The project you're working in is a git clone of the Gitea repo (so the remote URL is parseable). If not, you can set `GITEA_URL`/`GITEA_OWNER`/`GITEA_REPO` explicitly. + +## Gitea setup + +1. Log into Gitea, go to **Settings → Applications → Manage Access Tokens**. +2. Generate a token with scopes: + - `issue` — Read and Write + - `repository` — Read and Write +3. Add it to a `.env` file in your project root (gitignored): + +```env +GITEA_TOKEN=your_token_here +``` + +`GITEA_URL`, `GITEA_OWNER`, and `GITEA_REPO` are inferred from `git remote get-url origin`. Set them in `.env` only if you need to override the inferred values (mirrors, separate API host, etc.). + +## Verify + +```bash +uv run ${CLAUDE_PLUGIN_ROOT}/skills/gitea/scripts/ping.py +``` + +Should print the resolved URL/repo and `Connection: OK`. + +## Bootstrap labels (optional) + +```bash +uv run ${CLAUDE_PLUGIN_ROOT}/skills/gitea/scripts/setup_labels.py +``` + +Creates a default emoji-prefixed label set. Override per-project by placing `.gitea-labels.json` in the project root — its contents fully replace the default set. + +## Architecture notes + +- All scripts use **CWD-relative paths** for git, `.env`, and workpad lookups. The plugin directory is only consulted for shipped resources (like `labels.default.json`). +- Imports are **flat sibling imports** (`from client import …`) — scripts are run as files, not as a package. +- Each CLI entrypoint carries a PEP 723 `# /// script` header listing its own dependencies, so no shared environment, lockfile, or `pyproject.toml` is needed. + +## See also + +- The `gitea` skill's `SKILL.md` is the agent-facing reference. Read that to understand how Claude uses these scripts. +- A future sibling plugin will layer workflow glue (kanban, ticket-to-implementation flows) on top of this thin API surface. diff --git a/plugins/gitea/skills/gitea/SKILL.md b/plugins/gitea/skills/gitea/SKILL.md new file mode 100644 index 0000000..87bf0eb --- /dev/null +++ b/plugins/gitea/skills/gitea/SKILL.md @@ -0,0 +1,133 @@ +--- +name: gitea +description: Use when working with Gitea tickets — fetching ticket context, creating issues from workpad goals, posting plans or reports as comments, listing/searching issues, closing issues, or scaffolding standard labels for a new repo. +--- + +# Gitea Ticket Integration + +Bidirectional integration between the workpad and a Gitea instance. All operations are CLI scripts shipped with this plugin; nothing needs to be installed in the user's project. + +## Configuration + +The scripts derive Gitea connection settings in this order: + +1. **`git remote get-url origin`** in the current working directory. SSH and HTTPS forms both work. `base_url`, `owner`, and `repo` are parsed from it. +2. **Env vars** (`GITEA_URL`, `GITEA_OWNER`, `GITEA_REPO`) override anything inferred from the remote. Useful for mirrors or when the API host differs from the clone host. +3. **`GITEA_TOKEN`** is always required from env or `.env` — never inferable. + +The scripts call `python-dotenv`, which finds a `.env` in CWD (or any parent). So a project typically only needs `GITEA_TOKEN` set somewhere; everything else flows from the git remote. + +## Step 1: Verify connectivity + +Always run this before any other Gitea operation: + +```bash +uv run ${CLAUDE_PLUGIN_ROOT}/skills/gitea/scripts/ping.py +``` + +Prints the resolved URL/repo and `Connection: OK` on success. + +**On failure, stop.** Tell the user what's wrong: +- `missing required configuration` → which fields are missing +- `401` → token invalid or expired +- `403` → token lacks scopes (issue + repository read/write) +- `404` → wrong owner/repo, or token can't see a private repo +- Connection refused → check `GITEA_URL` (or the remote) includes the protocol + +Do not retry or work around auth failures. + +## Workpad path + +Most ticket-related scripts need a workpad path. Resolve it once at the start of the session: + +1. **`WORKPAD_FOLDER`** env var (read via `printenv WORKPAD_FOLDER`, or a `just workpad-folder` recipe if defined). +2. If unset, **ask the user** where the workpad is. Don't guess. + +Then pass `--workpad ` to every script that takes it. If the user keeps tickets *outside* the workpad, use `--tickets-dir ` instead (overrides the `/tickets/` default). + +## Available scripts + +All invocations use `uv run ${CLAUDE_PLUGIN_ROOT}/skills/gitea/scripts/.py`. Each script carries inline PEP 723 dependencies — uv builds an isolated env on first run and caches it. + +### `fetch_ticket.py --workpad [--tickets-dir ] [--no-comments]` + +Pulls a ticket as structured markdown to `//context.md` (default tickets dir = `/tickets`). Includes labels, assignees, milestone, description, and all comments unless `--no-comments`. + +### `start_ticket.py --workpad [--tickets-dir ] [--slug SLUG] [--no-checkout]` + +Recommended entry point when starting work on a ticket. Fetches the ticket, creates a branch `ticket/-` from current HEAD, writes `context.md` to the tickets dir. Slug is derived from the title if not given. Warns if the current branch isn't main/master. Fails if the branch already exists locally or on the remote. + +### `create_ticket.py (--from-goal | --title [--body ]) [--labels "l1,l2"]` + +Creates an issue. With `--from-goal `, the first `# heading` in the markdown becomes the title and the rest becomes the body. Unmatched labels are warned and skipped. + +### `post_comment.py ( | --stdin) [--header "## Title"]` + +Posts a markdown file (or stdin) as a comment on an existing issue. Use this after writing a plan, report, or design that should live on the ticket. + +### `list_tickets.py [--state open|closed|all] [--labels "l1,l2"] [--milestone NAME]` + +Lists matching issues. Defaults to open. + +### `close_ticket.py [ ...]` + +Closes one or more issues by number. Gitea also auto-closes when a commit on the default branch contains `closes #N`, `fixes #N`, or `resolves #N` — prefer that for natural workflow closes. + +### `setup_labels.py` + +Creates the standard label set in the repo (or a project-defined set if `.gitea-labels.json` exists in CWD — total replacement, not merge). Skips labels that already exist. Run once when bootstrapping a new Gitea project. + +## Common workflows + +### Start work on an existing ticket + +```bash +uv run ${CLAUDE_PLUGIN_ROOT}/skills/gitea/scripts/start_ticket.py 42 --workpad +``` + +Then read `/tickets/42/context.md` to understand the ticket. + +### Turn a workpad goal into a ticket + +```bash +uv run ${CLAUDE_PLUGIN_ROOT}/skills/gitea/scripts/create_ticket.py \ + --from-goal /goals/my-feature.md \ + --labels "✨ feature,📋 workpad" +``` + +### Post a plan back to the ticket + +```bash +uv run ${CLAUDE_PLUGIN_ROOT}/skills/gitea/scripts/post_comment.py 42 \ + /plans/2026-05-19-10-my-plan.md \ + --header "## Implementation Plan" +``` + +### Bootstrap a new Gitea project + +1. Create an API token (Gitea → Settings → Applications → Manage Access Tokens; scopes: `issue` read/write, `repository` read/write). +2. Put `GITEA_TOKEN=...` in a `.env` at the project root (gitignored). +3. `uv run ${CLAUDE_PLUGIN_ROOT}/skills/gitea/scripts/ping.py` +4. `uv run ${CLAUDE_PLUGIN_ROOT}/skills/gitea/scripts/setup_labels.py` + +That's it — URL/owner/repo come from `git remote get-url origin`. + +## Error recovery + +| Error | Action | +|-------|--------| +| Missing configuration | Stop. Report exactly which fields are missing. | +| 401 Unauthorized | Stop. Token expired/invalid. User must regenerate. | +| 403 Forbidden | Stop. Token lacks scopes. User must update permissions. | +| 404 Not Found | Check owner/repo match the Gitea instance. May also mean ticket doesn't exist. | +| Connection refused | Check URL protocol (`https://`) and that the host is reachable. | + +**On any auth or config error, do not retry. Report and stop.** + +## What NOT to do + +- Don't hardcode URLs or tokens in scripts or comments. +- Don't attempt to work around auth failures. +- Don't modify the plugin's scripts from within a user session — that's a separate plugin-development task. +- Don't skip the connectivity check. +- Don't guess the workpad path — read `WORKPAD_FOLDER` or ask. diff --git a/plugins/gitea/skills/gitea/labels.default.json b/plugins/gitea/skills/gitea/labels.default.json new file mode 100644 index 0000000..ec4443b --- /dev/null +++ b/plugins/gitea/skills/gitea/labels.default.json @@ -0,0 +1,9 @@ +[ + {"name": "🐛 bug", "color": "#d73a4a", "description": "Bug reports"}, + {"name": "✨ feature", "color": "#0075ca", "description": "Feature requests"}, + {"name": "🔍 investigation", "color": "#7057ff", "description": "Research/investigation tasks"}, + {"name": "📋 workpad", "color": "#bfd4f2", "description": "Created from workpad"}, + {"name": "🔧 enhancement", "color": "#a2eeef", "description": "Improvements to existing functionality"}, + {"name": "📖 documentation", "color": "#0075ca", "description": "Documentation updates"}, + {"name": "🚧 blocked", "color": "#e4e669", "description": "Blocked by external dependency"} +] diff --git a/plugins/gitea/skills/gitea/scripts/client.py b/plugins/gitea/skills/gitea/scripts/client.py new file mode 100644 index 0000000..9aaf1c1 --- /dev/null +++ b/plugins/gitea/skills/gitea/scripts/client.py @@ -0,0 +1,172 @@ +"""Gitea REST API client. + +Thin wrapper handling auth, pagination, and error mapping. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import httpx + +from config import GiteaConfig +from models import GiteaComment, GiteaCreateIssue, GiteaIssue, GiteaLabel + + +class GiteaError(Exception): + """Raised when a Gitea API call fails.""" + + def __init__(self, status_code: int, message: str): + self.status_code = status_code + super().__init__(f"Gitea API error {status_code}: {message}") + + +class GiteaClient: + """Client for the Gitea REST API v1.""" + + def __init__(self, config: GiteaConfig): + self.config = config + self._client = httpx.Client( + base_url=config.api_url, + headers={ + "Authorization": f"token {config.token}", + "Accept": "application/json", + "Content-Type": "application/json", + }, + timeout=30.0, + ) + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> "GiteaClient": + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + def _request(self, method: str, path: str, **kwargs: Any) -> Any: + resp = self._client.request(method, path, **kwargs) + if resp.status_code >= 400: + try: + detail = resp.json().get("message", resp.text) + except Exception: + detail = resp.text + raise GiteaError(resp.status_code, detail) + if resp.status_code == 204: + return None + return resp.json() + + def _paginate(self, path: str, params: Optional[Dict[str, Any]] = None) -> List[Any]: + params = dict(params or {}) + params.setdefault("limit", 50) + page = 1 + results: List[Any] = [] + while True: + params["page"] = page + data = self._request("GET", path, params=params) + if not data: + break + results.extend(data) + if len(data) < params["limit"]: + break + page += 1 + return results + + def get_issue(self, number: int, owner: str = "", repo: str = "") -> GiteaIssue: + owner = owner or self.config.owner + repo = repo or self.config.repo + data = self._request("GET", f"/repos/{owner}/{repo}/issues/{number}") + return GiteaIssue.model_validate(data) + + def list_issues( + self, + state: str = "open", + labels: Optional[List[str]] = None, + milestone: Optional[str] = None, + owner: str = "", + repo: str = "", + ) -> List[GiteaIssue]: + owner = owner or self.config.owner + repo = repo or self.config.repo + params: Dict[str, Any] = {"state": state, "type": "issues"} + if labels: + params["labels"] = ",".join(labels) + if milestone: + params["milestone"] = milestone + data = self._paginate(f"/repos/{owner}/{repo}/issues", params) + return [GiteaIssue.model_validate(item) for item in data] + + def create_issue( + self, payload: GiteaCreateIssue, owner: str = "", repo: str = "" + ) -> GiteaIssue: + owner = owner or self.config.owner + repo = repo or self.config.repo + data = self._request( + "POST", + f"/repos/{owner}/{repo}/issues", + json=payload.model_dump(exclude_none=True), + ) + return GiteaIssue.model_validate(data) + + def update_issue( + self, + number: int, + owner: str = "", + repo: str = "", + **fields: Any, + ) -> GiteaIssue: + owner = owner or self.config.owner + repo = repo or self.config.repo + data = self._request( + "PATCH", + f"/repos/{owner}/{repo}/issues/{number}", + json=fields, + ) + return GiteaIssue.model_validate(data) + + def get_comments( + self, issue_number: int, owner: str = "", repo: str = "" + ) -> List[GiteaComment]: + owner = owner or self.config.owner + repo = repo or self.config.repo + data = self._paginate(f"/repos/{owner}/{repo}/issues/{issue_number}/comments") + return [GiteaComment.model_validate(item) for item in data] + + def add_comment( + self, issue_number: int, body: str, owner: str = "", repo: str = "" + ) -> GiteaComment: + owner = owner or self.config.owner + repo = repo or self.config.repo + data = self._request( + "POST", + f"/repos/{owner}/{repo}/issues/{issue_number}/comments", + json={"body": body}, + ) + return GiteaComment.model_validate(data) + + def list_labels(self, owner: str = "", repo: str = "") -> List[GiteaLabel]: + owner = owner or self.config.owner + repo = repo or self.config.repo + data = self._paginate(f"/repos/{owner}/{repo}/labels") + return [GiteaLabel.model_validate(item) for item in data] + + def create_label( + self, name: str, color: str, description: str = "", owner: str = "", repo: str = "" + ) -> GiteaLabel: + owner = owner or self.config.owner + repo = repo or self.config.repo + data = self._request( + "POST", + f"/repos/{owner}/{repo}/labels", + json={"name": name, "color": color, "description": description}, + ) + return GiteaLabel.model_validate(data) + + def test_connection(self) -> bool: + """Verify the token and connection work via the repo endpoint.""" + try: + self._request("GET", f"/repos/{self.config.owner}/{self.config.repo}") + return True + except GiteaError: + return False diff --git a/plugins/gitea/skills/gitea/scripts/close_ticket.py b/plugins/gitea/skills/gitea/scripts/close_ticket.py new file mode 100644 index 0000000..bc453ec --- /dev/null +++ b/plugins/gitea/skills/gitea/scripts/close_ticket.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["httpx", "pydantic>=2", "python-dotenv"] +# /// +"""Close one or more Gitea issues. + +Usage: + uv run close_ticket.py 42 + uv run close_ticket.py 7 8 9 +""" + +from __future__ import annotations + +import argparse +import io +import sys + +from client import GiteaClient, GiteaError +from config import load_config + + +def main() -> None: + parser = argparse.ArgumentParser(description="Close Gitea issues.") + parser.add_argument("tickets", type=int, nargs="+", help="Issue numbers to close") + args = parser.parse_args() + + out = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + config = load_config() + try: + with GiteaClient(config) as client: + for num in args.tickets: + issue = client.update_issue(num, state="closed") + out.write(f"Closed #{issue.number}: {issue.title}\n") + out.flush() + except GiteaError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/plugins/gitea/skills/gitea/scripts/config.py b/plugins/gitea/skills/gitea/scripts/config.py new file mode 100644 index 0000000..1e197ce --- /dev/null +++ b/plugins/gitea/skills/gitea/scripts/config.py @@ -0,0 +1,117 @@ +"""Configuration for Gitea API access. + +Resolution order: + 1. Parse `git remote get-url origin` for base_url/owner/repo. + 2. Env vars (GITEA_URL, GITEA_OWNER, GITEA_REPO) override inferred values. + 3. GITEA_TOKEN is always required from env. + +`.env` in CWD (or any parent) is loaded via python-dotenv. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from typing import Optional, Tuple + +from dotenv import find_dotenv, load_dotenv + + +@dataclass(frozen=True) +class GiteaConfig: + base_url: str + token: str + owner: str + repo: str + + @property + def api_url(self) -> str: + return f"{self.base_url.rstrip('/')}/api/v1" + + +_HTTPS_RE = re.compile(r"^(https?)://(?:[^@/]+@)?([^/:]+)(?::\d+)?/([^/]+)/(.+?)(?:\.git)?/?$") +_SSH_URL_RE = re.compile(r"^ssh://(?:[\w.-]+@)?([^:/]+)(?::\d+)?/([^/]+)/(.+?)(?:\.git)?/?$") +_SCP_RE = re.compile(r"^(?:[\w.-]+@)?([^:/]+):([^/]+)/(.+?)(?:\.git)?/?$") + + +def parse_remote_url(url: str) -> Optional[Tuple[str, str, str]]: + """Parse a git remote URL into (base_url, owner, repo). + + Handles: + git@host:owner/repo.git (SCP-like) + ssh://git@host[:port]/owner/repo.git (SSH URL) + https://[user@]host[:port]/owner/repo(.git) + """ + url = url.strip() + if not url: + return None + + m = _HTTPS_RE.match(url) + if m: + scheme, host, owner, repo = m.group(1), m.group(2), m.group(3), m.group(4) + return f"{scheme}://{host}", owner, repo + + m = _SSH_URL_RE.match(url) + if m: + host, owner, repo = m.group(1), m.group(2), m.group(3) + return f"https://{host}", owner, repo + + m = _SCP_RE.match(url) + if m: + host, owner, repo = m.group(1), m.group(2), m.group(3) + return f"https://{host}", owner, repo + + return None + + +def git_remote_origin() -> Optional[Tuple[str, str, str]]: + """Return (base_url, owner, repo) parsed from `git remote get-url origin`, or None.""" + try: + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + capture_output=True, + text=True, + timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + return parse_remote_url(result.stdout.strip()) + + +def load_config() -> GiteaConfig: + """Load configuration: git remote inference + env-var overrides. + + GITEA_TOKEN is always required. The other fields can come from either + the git remote of the current working directory, or be overridden via + GITEA_URL, GITEA_OWNER, GITEA_REPO in env or .env file. + """ + load_dotenv(find_dotenv(usecwd=True)) + + inferred = git_remote_origin() + base_url = os.environ.get("GITEA_URL") or (inferred[0] if inferred else None) + owner = os.environ.get("GITEA_OWNER") or (inferred[1] if inferred else None) + repo = os.environ.get("GITEA_REPO") or (inferred[2] if inferred else None) + token = os.environ.get("GITEA_TOKEN") + + missing = [] + if not token: + missing.append("GITEA_TOKEN") + if not base_url: + missing.append("GITEA_URL (or a git origin remote)") + if not owner: + missing.append("GITEA_OWNER (or a git origin remote)") + if not repo: + missing.append("GITEA_REPO (or a git origin remote)") + + if missing: + print(f"Error: missing required configuration: {', '.join(missing)}", file=sys.stderr) + print("Set GITEA_TOKEN in your environment or .env file.", file=sys.stderr) + print("URL/owner/repo are inferred from `git remote get-url origin` when available.", file=sys.stderr) + sys.exit(1) + + return GiteaConfig(base_url=base_url, token=token, owner=owner, repo=repo) diff --git a/plugins/gitea/skills/gitea/scripts/create_ticket.py b/plugins/gitea/skills/gitea/scripts/create_ticket.py new file mode 100644 index 0000000..3aafba8 --- /dev/null +++ b/plugins/gitea/skills/gitea/scripts/create_ticket.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["httpx", "pydantic>=2", "python-dotenv"] +# /// +"""Create a Gitea issue from a goal file or inline arguments. + +Usage: + uv run create_ticket.py --from-goal path/to/goal.md + uv run create_ticket.py --title "Title" --body "Body" --labels "bug,urgent" +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path +from typing import List, Optional + +from client import GiteaClient, GiteaError +from config import load_config +from models import GiteaCreateIssue + + +def parse_goal_file(path: Path) -> tuple[str, str]: + text = path.read_text(encoding="utf-8") + lines = text.splitlines() + title = path.stem.replace("-", " ").replace("_", " ").title() + body_start = 0 + for i, line in enumerate(lines): + m = re.match(r"^#\s+(.+)", line) + if m: + title = m.group(1).strip() + body_start = i + 1 + break + body = "\n".join(lines[body_start:]).strip() + return title, body + + +def resolve_label_ids(client: GiteaClient, label_names: List[str]) -> List[int]: + if not label_names: + return [] + repo_labels = client.list_labels() + name_to_id = {label.name.lower(): label.id for label in repo_labels} + ids = [] + for name in label_names: + lid = name_to_id.get(name.lower()) + if lid: + ids.append(lid) + else: + print(f"Warning: label '{name}' not found in repo, skipping.", file=sys.stderr) + return ids + + +def create_from_args(title: str, body: str = "", label_names: Optional[List[str]] = None) -> None: + config = load_config() + with GiteaClient(config) as client: + label_ids = resolve_label_ids(client, label_names or []) + payload = GiteaCreateIssue(title=title, body=body, labels=label_ids) + issue = client.create_issue(payload) + print(f"Created issue #{issue.number}: {issue.title}") + if issue.html_url: + print(f"URL: {issue.html_url}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Create a Gitea issue.") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--from-goal", type=Path, help="Path to a markdown goal file") + group.add_argument("--title", type=str, help="Issue title (inline mode)") + parser.add_argument("--body", type=str, default="", help="Issue body (inline mode)") + parser.add_argument("--labels", type=str, default="", help="Comma-separated label names") + args = parser.parse_args() + + label_names = [l.strip() for l in args.labels.split(",") if l.strip()] if args.labels else [] + + try: + if args.from_goal: + if not args.from_goal.exists(): + print(f"Error: goal file not found: {args.from_goal}", file=sys.stderr) + sys.exit(1) + title, body = parse_goal_file(args.from_goal) + create_from_args(title, body, label_names) + else: + create_from_args(args.title, args.body, label_names) + except GiteaError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/plugins/gitea/skills/gitea/scripts/fetch_ticket.py b/plugins/gitea/skills/gitea/scripts/fetch_ticket.py new file mode 100644 index 0000000..8763c45 --- /dev/null +++ b/plugins/gitea/skills/gitea/scripts/fetch_ticket.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["httpx", "pydantic>=2", "python-dotenv"] +# /// +"""Fetch a Gitea ticket and write structured markdown. + +Usage: + uv run fetch_ticket.py --workpad [--tickets-dir ] [--no-comments] + +Output: //context.md + (default tickets-dir = /tickets) +""" + +from __future__ import annotations + +import argparse +import sys +from datetime import datetime +from pathlib import Path +from typing import List + +from client import GiteaClient, GiteaError +from config import load_config +from models import GiteaComment, GiteaIssue + + +def render_issue_markdown(issue: GiteaIssue, comments: List[GiteaComment] | None = None) -> str: + lines: list[str] = [] + lines.append(f"# #{issue.number}: {issue.title}") + lines.append("") + lines.append(f"**State:** {issue.state}") + lines.append(f"**Created:** {issue.created_at.strftime('%Y-%m-%d %H:%M')}") + lines.append(f"**Author:** {issue.user.login}") + if issue.labels: + lines.append(f"**Labels:** {', '.join(issue.label_names())}") + if issue.milestone: + lines.append(f"**Milestone:** {issue.milestone.title}") + if issue.assignees: + lines.append(f"**Assignees:** {', '.join(a.login for a in issue.assignees)}") + if issue.html_url: + lines.append(f"**URL:** {issue.html_url}") + lines.append("") + lines.append("---") + lines.append("") + lines.append("## Description") + lines.append("") + lines.append(issue.body if issue.body else "*No description provided.*") + lines.append("") + if comments: + lines.append("---") + lines.append("") + lines.append(f"## Comments ({len(comments)})") + lines.append("") + for comment in comments: + lines.append(f"### {comment.user.login} ({comment.created_at.strftime('%Y-%m-%d %H:%M')})") + lines.append("") + lines.append(comment.body) + lines.append("") + lines.append("---") + lines.append("") + lines.append(f"*Fetched at {datetime.now().strftime('%Y-%m-%d %H:%M')}*") + lines.append("") + return "\n".join(lines) + + +def resolve_tickets_dir(workpad: Path | None, tickets_dir: Path | None) -> Path: + if tickets_dir is not None: + return tickets_dir + if workpad is None: + print("Error: either --workpad or --tickets-dir must be provided.", file=sys.stderr) + sys.exit(1) + return workpad / "tickets" + + +def fetch_and_write(ticket_number: int, target_dir: Path, include_comments: bool = True) -> Path: + config = load_config() + with GiteaClient(config) as client: + issue = client.get_issue(ticket_number) + comments = None + if include_comments and issue.comments > 0: + comments = client.get_comments(ticket_number) + md = render_issue_markdown(issue, comments) + out_dir = target_dir / str(ticket_number) + out_dir.mkdir(parents=True, exist_ok=True) + out_file = out_dir / "context.md" + out_file.write_text(md, encoding="utf-8") + return out_file + + +def main() -> None: + parser = argparse.ArgumentParser(description="Fetch a Gitea ticket as markdown.") + parser.add_argument("ticket", type=int, help="Ticket number to fetch") + parser.add_argument("--workpad", type=Path, default=None, help="Workpad root (tickets go to /tickets//)") + parser.add_argument("--tickets-dir", type=Path, default=None, help="Direct tickets directory (overrides workpad)") + parser.add_argument("--no-comments", action="store_true", help="Skip fetching comments") + args = parser.parse_args() + + target = resolve_tickets_dir(args.workpad, args.tickets_dir) + try: + out = fetch_and_write(args.ticket, target, include_comments=not args.no_comments) + print(f"Wrote ticket #{args.ticket} to {out}") + except GiteaError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/plugins/gitea/skills/gitea/scripts/list_tickets.py b/plugins/gitea/skills/gitea/scripts/list_tickets.py new file mode 100644 index 0000000..fad1b8c --- /dev/null +++ b/plugins/gitea/skills/gitea/scripts/list_tickets.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["httpx", "pydantic>=2", "python-dotenv"] +# /// +"""List Gitea issues. + +Usage: + uv run list_tickets.py [--state open|closed|all] [--labels "l1,l2"] [--milestone NAME] +""" + +from __future__ import annotations + +import argparse +import io +import sys +from typing import List, Optional + +from client import GiteaClient, GiteaError +from config import load_config + + +def main() -> None: + parser = argparse.ArgumentParser(description="List Gitea issues.") + parser.add_argument("--state", type=str, default="open", help="Issue state: open, closed, all (default: open)") + parser.add_argument("--labels", type=str, default=None, help="Comma-separated label names") + parser.add_argument("--milestone", type=str, default=None, help="Milestone name") + args = parser.parse_args() + + out = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + label_list: Optional[List[str]] = None + if args.labels: + label_list = [l.strip() for l in args.labels.split(",") if l.strip()] + + config = load_config() + try: + with GiteaClient(config) as client: + issues = client.list_issues(state=args.state, labels=label_list, milestone=args.milestone) + if not issues: + out.write(f"No {args.state} issues found.\n") + else: + for i in issues: + labels = f" [{', '.join(i.label_names())}]" if i.labels else "" + out.write(f"#{i.number}: {i.title}{labels}\n") + out.flush() + except GiteaError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/plugins/gitea/skills/gitea/scripts/models.py b/plugins/gitea/skills/gitea/scripts/models.py new file mode 100644 index 0000000..648ff90 --- /dev/null +++ b/plugins/gitea/skills/gitea/scripts/models.py @@ -0,0 +1,66 @@ +"""Pydantic models for Gitea API responses.""" + +from __future__ import annotations + +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class GiteaLabel(BaseModel): + id: int + name: str + color: str = "" + description: str = "" + + +class GiteaMilestone(BaseModel): + id: int + title: str + description: str = "" + state: str = "" + due_on: Optional[datetime] = None + + +class GiteaUser(BaseModel): + id: int + login: str + full_name: str = "" + + +class GiteaComment(BaseModel): + id: int + body: str + user: GiteaUser + created_at: datetime + updated_at: datetime + + +class GiteaIssue(BaseModel): + id: int + number: int + title: str + body: str = "" + state: str = "open" + labels: Optional[List[GiteaLabel]] = Field(default_factory=list) + milestone: Optional[GiteaMilestone] = None + assignees: Optional[List[GiteaUser]] = Field(default_factory=list) + user: GiteaUser + created_at: datetime + updated_at: datetime + closed_at: Optional[datetime] = None + html_url: str = "" + comments: int = 0 + + def label_names(self) -> List[str]: + return [label.name for label in (self.labels or [])] + + +class GiteaCreateIssue(BaseModel): + """Payload for creating a new issue.""" + title: str + body: str = "" + labels: List[int] = Field(default_factory=list) + milestone: Optional[int] = None + assignees: List[str] = Field(default_factory=list) diff --git a/plugins/gitea/skills/gitea/scripts/ping.py b/plugins/gitea/skills/gitea/scripts/ping.py new file mode 100644 index 0000000..ef04fb1 --- /dev/null +++ b/plugins/gitea/skills/gitea/scripts/ping.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["httpx", "pydantic>=2", "python-dotenv"] +# /// +"""Verify Gitea connectivity. Exits 0 on success, 1 on any failure.""" + +from __future__ import annotations + +import sys + +from client import GiteaClient, GiteaError +from config import load_config + + +def main() -> None: + config = load_config() + print(f"Gitea URL : {config.base_url}") + print(f"Repo : {config.owner}/{config.repo}") + try: + with GiteaClient(config) as client: + if client.test_connection(): + print("Connection: OK") + return + print("Connection: FAIL (repo not accessible)", file=sys.stderr) + sys.exit(1) + except GiteaError as e: + print(f"Connection: FAIL ({e})", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/plugins/gitea/skills/gitea/scripts/post_comment.py b/plugins/gitea/skills/gitea/scripts/post_comment.py new file mode 100644 index 0000000..7572aac --- /dev/null +++ b/plugins/gitea/skills/gitea/scripts/post_comment.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["httpx", "pydantic>=2", "python-dotenv"] +# /// +"""Post a markdown file (or stdin) as a comment on a Gitea issue. + +Usage: + uv run post_comment.py 42 path/to/file.md [--header "## Title"] + echo "text" | uv run post_comment.py 42 --stdin +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from client import GiteaClient, GiteaError +from config import load_config + + +def post_comment(issue_number: int, body: str, header: str | None = None) -> None: + if header: + body = f"{header}\n\n{body}" + config = load_config() + with GiteaClient(config) as client: + comment = client.add_comment(issue_number, body) + print(f"Posted comment on #{issue_number} (comment id: {comment.id})") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Post a markdown file as a comment on a Gitea issue.") + parser.add_argument("issue", type=int, help="Issue number") + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("file", nargs="?", type=Path, help="Path to markdown file") + source.add_argument("--stdin", action="store_true", help="Read body from stdin") + parser.add_argument("--header", type=str, default=None, help="Optional header prepended to the comment") + args = parser.parse_args() + + try: + if args.stdin: + body = sys.stdin.buffer.read().decode("utf-8") + else: + if not args.file.exists(): + print(f"Error: file not found: {args.file}", file=sys.stderr) + sys.exit(1) + body = args.file.read_text(encoding="utf-8") + + if not body.strip(): + print("Error: empty body, nothing to post.", file=sys.stderr) + sys.exit(1) + + post_comment(args.issue, body, header=args.header) + except GiteaError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/plugins/gitea/skills/gitea/scripts/setup_labels.py b/plugins/gitea/skills/gitea/scripts/setup_labels.py new file mode 100644 index 0000000..79717b8 --- /dev/null +++ b/plugins/gitea/skills/gitea/scripts/setup_labels.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["httpx", "pydantic>=2", "python-dotenv"] +# /// +"""Set up labels in the configured Gitea repository. + +Lookup order for the label set: + 1. `.gitea-labels.json` in CWD (project override, total replacement of defaults). + 2. `labels.default.json` shipped with the plugin (one level up from this script). + +Skips labels that already exist in the repo (name match, case-insensitive). + +Usage: + uv run setup_labels.py +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +from client import GiteaClient +from config import load_config + + +def load_labels() -> list[dict]: + project_override = Path.cwd() / ".gitea-labels.json" + if project_override.exists(): + source = project_override + else: + source = Path(__file__).resolve().parent.parent / "labels.default.json" + if not source.exists(): + print(f"Error: no labels file found at {source}", file=sys.stderr) + sys.exit(1) + data = json.loads(source.read_text(encoding="utf-8")) + if not isinstance(data, list): + print(f"Error: {source} must contain a JSON array of label objects.", file=sys.stderr) + sys.exit(1) + return data + + +def main() -> None: + labels = load_labels() + config = load_config() + with GiteaClient(config) as client: + existing = {label.name.lower() for label in client.list_labels()} + for label in labels: + name = label["name"] + if name.lower() in existing: + print(f" skip {name} (already exists)") + else: + client.create_label( + name, + label.get("color", "#cccccc"), + label.get("description", ""), + ) + print(f" created {name}") + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/plugins/gitea/skills/gitea/scripts/start_ticket.py b/plugins/gitea/skills/gitea/scripts/start_ticket.py new file mode 100644 index 0000000..1ff460c --- /dev/null +++ b/plugins/gitea/skills/gitea/scripts/start_ticket.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["httpx", "pydantic>=2", "python-dotenv"] +# /// +"""Start working on a Gitea ticket: create a branch and fetch ticket context. + +Usage: + uv run start_ticket.py --workpad [--tickets-dir ] [--slug SLUG] [--no-checkout] +""" + +from __future__ import annotations + +import argparse +import io +import re +import subprocess +import sys +import unicodedata +from pathlib import Path + +from client import GiteaClient, GiteaError +from config import load_config +from fetch_ticket import render_issue_markdown, resolve_tickets_dir + + +def slugify(text: str, max_length: int = 48) -> str: + text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii") + text = text.lower() + text = re.sub(r"[^a-z0-9]+", "-", text) + text = text.strip("-") + if len(text) <= max_length: + return text + truncated = text[:max_length] + last_hyphen = truncated.rfind("-") + if last_hyphen > max_length // 2: + truncated = truncated[:last_hyphen] + return truncated.rstrip("-") + + +def git(*args: str) -> tuple[int, str]: + result = subprocess.run(["git", *args], capture_output=True, text=True) + return result.returncode, result.stdout.strip() + + +def branch_exists_locally(name: str) -> bool: + code, _ = git("rev-parse", "--verify", name) + return code == 0 + + +def branch_exists_remotely(name: str) -> bool: + code, output = git("ls-remote", "--heads", "origin", name) + return code == 0 and bool(output) + + +def current_branch() -> str: + _, name = git("rev-parse", "--abbrev-ref", "HEAD") + return name + + +def main() -> None: + parser = argparse.ArgumentParser(description="Start working on a Gitea ticket.") + parser.add_argument("ticket", type=int, help="Ticket number") + parser.add_argument("--workpad", type=Path, default=None, help="Workpad root") + parser.add_argument("--tickets-dir", type=Path, default=None, help="Direct tickets directory (overrides workpad)") + parser.add_argument("--slug", type=str, default=None, help="Custom branch slug") + parser.add_argument("--no-checkout", action="store_true", help="Create branch without switching to it") + args = parser.parse_args() + + target = resolve_tickets_dir(args.workpad, args.tickets_dir) + + config = load_config() + try: + with GiteaClient(config) as client: + issue = client.get_issue(args.ticket) + comments = None + if issue.comments > 0: + comments = client.get_comments(args.ticket) + except GiteaError as e: + print(f"Error fetching ticket: {e}", file=sys.stderr) + sys.exit(1) + + slug = args.slug or slugify(issue.title) + if "/" in slug: + print(f"Error: slug contains '/': {slug}", file=sys.stderr) + print("Branch names must have exactly one subpath (ticket/-).", file=sys.stderr) + sys.exit(1) + + branch_name = f"ticket/{args.ticket}-{slug}" + + if branch_exists_locally(branch_name): + print(f"Error: branch '{branch_name}' already exists locally.", file=sys.stderr) + print(f"Resume with: git checkout {branch_name}", file=sys.stderr) + sys.exit(1) + + if branch_exists_remotely(branch_name): + print(f"Error: branch '{branch_name}' already exists on remote.", file=sys.stderr) + print(f"Resume with: git checkout -b {branch_name} origin/{branch_name}", file=sys.stderr) + sys.exit(1) + + cur = current_branch() + if cur not in ("main", "master"): + print(f"Warning: you are on '{cur}', not main. Branch will be created from here.", file=sys.stderr) + + if args.no_checkout: + code, _ = git("branch", branch_name) + else: + code, _ = git("checkout", "-b", branch_name) + + if code != 0: + print(f"Error: failed to create branch '{branch_name}'.", file=sys.stderr) + sys.exit(1) + + ticket_dir = target / str(args.ticket) + ticket_dir.mkdir(parents=True, exist_ok=True) + md = render_issue_markdown(issue, comments) + context_file = ticket_dir / "context.md" + context_file.write_text(md, encoding="utf-8") + + out = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + action = "Created" if args.no_checkout else "Checked out" + out.write(f"{action} branch: {branch_name}\n") + out.write(f"Ticket context: {context_file}\n") + out.write(f"Issue: #{issue.number} - {issue.title}\n") + if issue.labels: + out.write(f"Labels: {', '.join(issue.label_names())}\n") + out.flush() + + +if __name__ == "__main__": + main()