Files
reliquary/docs/workpad/designs/2026-05-11-17-gitea-skill-port.md
g4borg c5b189a73c 📝 Refine gitea skill port design with resolved decisions
Folds in decisions made during the design review: flat sibling imports
+ PEP 723, CWD-based paths, git-remote inference with env overrides,
workpad/tickets parameterization, project label overrides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 19:51:51 +02:00

11 KiB

Porting the gitea skill + scripts into reliquary

Hands-on design for moving dirigent's gitea skill (which is a thin wrapper around scripts/gitea/*.py in the dirigent repo) into a reliquary plugin where the scripts and the skill ship as one unit.

Current state in dirigent

dirigent/
├── .claude/skills/gitea/SKILL.md          # 123 LOC — thin wrapper, points at scripts
└── scripts/gitea/
    ├── CLAUDE.md          # agent reference for scripts (~127 LOC)
    ├── README.md          # human setup guide (~178 LOC)
    ├── __init__.py
    ├── client.py          # 186 LOC — GiteaClient REST wrapper
    ├── close_ticket.py    # 39 LOC
    ├── config.py          # 68 LOC — env loading
    ├── create_ticket.py   # 115 LOC
    ├── fetch_ticket.py    # 113 LOC
    ├── list_tickets.py    # 56 LOC
    ├── models.py          # 66 LOC — Pydantic models
    ├── post_comment.py    # 69 LOC
    ├── setup_labels.py    # 43 LOC
    └── start_ticket.py    # 163 LOC

Total: ~919 LOC of Python + ~430 LOC of docs.

The skill itself does almost nothing — it tells the agent to read scripts/gitea/CLAUDE.md and run uv run python -m scripts.gitea.<module>. Everything load-bearing lives in the dirigent project root: the venv, uv.lock, pyproject.toml, and .env.

Target shape in reliquary

plugins/gitea/
├── .claude-plugin/plugin.json
├── README.md
└── skills/gitea/
    ├── SKILL.md
    ├── labels.default.json          # ships with plugin
    └── scripts/
        ├── client.py                # flat layout — no package
        ├── config.py
        ├── models.py
        ├── ping.py                  # standalone connectivity check
        ├── fetch_ticket.py
        ├── create_ticket.py
        ├── post_comment.py
        ├── start_ticket.py
        ├── list_tickets.py
        ├── close_ticket.py
        └── setup_labels.py

Single SKILL.md, no separate CLAUDE.md. No __init__.py. Each entrypoint is a self-contained PEP 723 script.

Decisions (resolved during design review)

1. Module layout: flat sibling imports + PEP 723 per file

Each CLI entrypoint carries an inline-deps block:

# /// script
# requires-python = ">=3.11"
# dependencies = ["httpx", "pydantic>=2", "python-dotenv"]
# ///

Imports become flat — import client, from client import GiteaClient, from fetch_ticket import render_issue_markdown. The script's own directory is on sys.path when run as a file, so no package context is needed.

Invocation: uv run ${CLAUDE_PLUGIN_ROOT}/skills/gitea/scripts/fetch_ticket.py 42.

Tradeoff: uv run builds an isolated env per script on first call (slow once, cached after). Worth it for hermetic execution against any project regardless of its own Python setup.

2. CWD-relative, not script-relative

The dirigent scripts derive PROJECT_ROOT from Path(__file__).resolve().parent.parent.parent. After the port that resolves to the plugin directory, which is wrong for every git/workpad/env operation.

All scripts must use Path.cwd() for:

  • git operations (subprocess.run([...], cwd=Path.cwd()) or just no cwd arg)
  • workpad/ticket file writes
  • .env discovery (call dotenv.load_dotenv() with no args — finds .env in CWD and walks up)

Path(__file__) is only used to locate sibling resources shipped with the plugin (e.g. labels.default.json).

3. Where Gitea lives — infer from git remote, override via env

config.py resolves connection settings in this order:

  1. Parse git remote get-url origin. Handle both forms:
    • git@host:owner/repo.gitbase_url=https://host, owner, repo
    • https://host/owner/repo(.git)? → same fields
  2. Env vars override inferred values. Any of GITEA_URL, GITEA_OWNER, GITEA_REPO set in env (or .env) wins over what's parsed from the remote. Covers mirrors, multi-remote setups, separate API host.
  3. GITEA_TOKEN is always required — never inferable.

No host sniffing — the script doesn't try to guess whether the remote is Gitea, GitHub, or anything else. It just makes the API call; the connectivity check surfaces a wrong host as HTTP 404 or a malformed response.

If git remote get-url origin fails (not a git repo, no origin) and any of URL/owner/repo isn't set in env, exit with a clear error listing what's missing.

4. Workpad resolution lives in the skill, not the script

Scripts take a required --workpad <path> flag. They do not resolve the workpad themselves.

The skill resolves it before invoking:

  1. WORKPAD_FOLDER env var (or .env-loaded value).
  2. If unset, ask the user — or accept a value already established in conversation.

No /docs/workpad auto-fallback in this skill — the workpad skill itself handles project-level placement; gitea just consumes the resolved path.

5. Tickets folder is independently overridable

The workpad's tickets/ subfolder is not the only valid home for per-ticket scratch. In some projects, tickets live outside the workpad entirely.

Resolution:

  1. --tickets-dir <path> flag if passed (skill can populate from GITEA_TICKETS_DIR env).
  2. Else <workpad>/tickets/<n>/.

When --tickets-dir is given, write directly to <tickets-dir>/<n>/context.md — no workpad/ join. mkdir(parents=True, exist_ok=True) on demand.

6. Labels: plugin default + project override

  • plugins/gitea/skills/gitea/labels.default.json ships with the plugin and contains the de-dirigent-ed default set.
  • .gitea-labels.json in CWD, if present, fully replaces the default (not merged — keeps semantics simple).
  • setup_labels.py loads whichever applies, creates missing labels, skips existing ones (case-insensitive name match).

The label description "Created from dirigent workpad" becomes "Created from workpad".

7. Connectivity check becomes a script

The SKILL's Step 1 inline python -c doesn't survive the package rename. Replace with:

uv run ${CLAUDE_PLUGIN_ROOT}/skills/gitea/scripts/ping.py

ping.py does what test_connection() did: load config, hit /repos/{owner}/{repo}, print OK or a categorized error (missing env, 401, 404, connection refused) and exit non-zero on failure.

Concrete port checklist

Phase 1 — copy + flatten

  1. Copy dirigent/scripts/gitea/*.pyplugins/gitea/skills/gitea/scripts/.
  2. Delete __init__.py.
  3. Rewrite every from .client import …from client import …. Same for .config, .models, .fetch_ticket.
  4. Add PEP 723 header to each CLI entrypoint (fetch_ticket.py, create_ticket.py, post_comment.py, start_ticket.py, list_tickets.py, close_ticket.py, setup_labels.py, ping.py). Library files (client.py, config.py, models.py) don't need it.

Phase 2 — CWD + remote inference

  1. Replace PROJECT_ROOT = Path(__file__)... in start_ticket.py with Path.cwd() usage. Remove the constant.
  2. Switch config.py .env loading from custom code to dotenv.load_dotenv() (no path arg).
  3. Add git_remote_origin() helper in config.py: runs git remote get-url origin, parses SSH/HTTPS forms, returns (base_url, owner, repo) or None. Use it as the base in load_config(); env vars override.
  4. Drop the "See scripts/gitea/README.md" hint in the missing-env error and replace with skill-aware wording.

Phase 3 — parameterize workpad + tickets + labels

  1. Add --workpad <path> (required) and --tickets-dir <path> (optional) to fetch_ticket.py and start_ticket.py. Default tickets dir = <workpad>/tickets/.
  2. Add --workpad <path> to create_ticket.py (for --from-goal path resolution — though goals are typically given as explicit paths, so this may be unnecessary; decide during implementation).
  3. Build labels.default.json from the current hardcoded list, de-dirigent-ed.
  4. setup_labels.py: load .gitea-labels.json from CWD if present, else Path(__file__).parent.parent / "labels.default.json".

Phase 4 — skill markdown + manifest

  1. Rewrite SKILL.md from scratch:
    • Inline capability summary for each script (no separate CLAUDE.md).
    • Step 0 (read CLAUDE.md) deleted.
    • Step 1 (connectivity) calls ping.py.
    • All invocations use uv run ${CLAUDE_PLUGIN_ROOT}/skills/gitea/scripts/<name>.py.
    • Workpad resolution paragraph: env → ask, then pass via --workpad.
    • Keep the error-recovery table; update path references.
  2. plugin.json: name gitea, version 0.1.0, author Gabor Körber, description matches the marketplace tone of other plugins in this repo.
  3. README.md at plugin root: install, token setup, env var reference (only GITEA_TOKEN mandatory now), example session.

Phase 5 — manual end-to-end

  1. Real Gitea + a non-dirigent project. Test: ping, setup_labels (with project override file present and absent), list_tickets, fetch_ticket, start_ticket (cwd-relative git!), post_comment, close_ticket. This is where bugs surface.

What we are not doing

  • No python -m invocation, no shared venv, no project-level dependency installation.
  • No host sniffing — the script doesn't decide whether a remote is "Gitea-shaped" before calling.
  • No merge between project label file and plugin default — override is total replacement.
  • No PR-related endpoints. Issues only, same as the dirigent scripts.
  • No workflow glue (write-plan-and-comment, ticket-to-branch-with-implementation, etc.). That's a separate sibling plugin, addressed below.

Sibling "mini plugin" — future scope, not in this port

A separate plugin (working name: gitea-workflow or similar) layers opinionated flows on top of the API skill:

  • kanban skill — list/move issues across columns, mark in-progress, query "what am I working on right now".
  • workpad-flow skill — fetch ticket → write plan in workpad → post plan back as comment → branch via start_ticket → final commit with closes #N.
  • bulk skill — relabel many issues, close stale ones, milestone grooming.

Out of scope for this port. The core gitea plugin stays a thin, opinion-light API wrapper so the workflow plugin can be swapped or extended without touching the API surface.

Estimated work

Phase Effort Risk
Phase 1 (copy + flatten + PEP 723) ~45min Low — mechanical
Phase 2 (CWD + remote inference) ~1h Medium — git remote parsing has edge cases
Phase 3 (workpad/tickets/labels params) ~1h Low
Phase 4 (SKILL.md + manifest + README) ~45min Low
Phase 5 (end-to-end test) ~1h This is where bugs hide

Total: ~4.5h focused work. Highest-risk pieces: start_ticket (touches git state + workpad + API in one call) and the remote-URL parser (SSH alias forms, port numbers, trailing .git, etc.).