Add gitea plugin

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 19:52:02 +02:00
parent c5b189a73c
commit 68c112d2c8
15 changed files with 1154 additions and 0 deletions
+9
View File
@@ -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"
}
}
+63
View File
@@ -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.
+133
View File
@@ -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 <resolved-path>` to every script that takes it. If the user keeps tickets *outside* the workpad, use `--tickets-dir <path>` instead (overrides the `<workpad>/tickets/` default).
## Available scripts
All invocations use `uv run ${CLAUDE_PLUGIN_ROOT}/skills/gitea/scripts/<name>.py`. Each script carries inline PEP 723 dependencies — uv builds an isolated env on first run and caches it.
### `fetch_ticket.py <number> --workpad <path> [--tickets-dir <path>] [--no-comments]`
Pulls a ticket as structured markdown to `<tickets-dir>/<number>/context.md` (default tickets dir = `<workpad>/tickets`). Includes labels, assignees, milestone, description, and all comments unless `--no-comments`.
### `start_ticket.py <number> --workpad <path> [--tickets-dir <path>] [--slug SLUG] [--no-checkout]`
Recommended entry point when starting work on a ticket. Fetches the ticket, creates a branch `ticket/<number>-<slug>` 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 <file> | --title <str> [--body <str>]) [--labels "l1,l2"]`
Creates an issue. With `--from-goal <file>`, the first `# heading` in the markdown becomes the title and the rest becomes the body. Unmatched labels are warned and skipped.
### `post_comment.py <number> (<file> | --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 <number> [<number> ...]`
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 <workpad>
```
Then read `<workpad>/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 <workpad>/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 \
<workpad>/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.
@@ -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"}
]
@@ -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
@@ -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()
@@ -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)
@@ -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()
@@ -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 <number> --workpad <path> [--tickets-dir <path>] [--no-comments]
Output: <tickets-dir>/<number>/context.md
(default tickets-dir = <workpad>/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 <workpad>/tickets/<n>/)")
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()
@@ -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()
@@ -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)
@@ -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()
@@ -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()
@@ -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()
@@ -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 <number> --workpad <path> [--tickets-dir <path>] [--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/<number>-<slug>).", 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()