✨ 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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user