Publish training skills collection

This commit is contained in:
2026-08-12 16:59:21 -04:00
commit bbfeac41de
24 changed files with 2041 additions and 0 deletions
+315
View File
@@ -0,0 +1,315 @@
---
name: gitea-issues
description: Read, create, draft, claim, implement from, label, comment on, and manage Gitea repositories, issues, pull requests, and epic-style dependency links using the Gitea API. Use when the user asks to create a Gitea repository, publish a local repo to Gitea, inspect an issue, summarize an issue, create an issue, publish issues to Gitea, draft Gitea work items, create epic cards, attach child issues, take the next ready issue from an epic, create a branch or PR for an issue, or update ready/in-progress/review/done issue status labels.
metadata:
short-description: Work from Gitea issues
---
# Gitea Issues
Use this skill to create repositories and to read, draft, create, claim, implement from, and update issues and pull requests in the user's Gitea repository.
## Safety
Never ask the user to paste tokens in chat. Authentication must come from environment variables, the current git remote, or a local env file.
The helpers resolve configuration in this order:
1. Explicit environment variables.
2. The current repository's `origin` remote for `GITEA_BASE_URL`, `GITEA_OWNER`, and `GITEA_REPO`.
3. `~/.config/gitea/gitea.env`.
4. `~/.config/secrets/gitea.env`.
5. The legacy fallback `~/.config/gitea/data-analysis-agent.env`.
A generic env file can look like:
```text
export GITEA_BASE_URL="https://tea.example.com"
export GITEA_TOKEN="..."
```
Required values:
- `GITEA_BASE_URL`
- `GITEA_OWNER` or a Gitea `origin` remote
- `GITEA_REPO` or a Gitea `origin` remote
- `GITEA_TOKEN`
Creating pull requests requires a token with repository read/write scope. Issue-only tokens can read and label issues but cannot call the Gitea pulls API.
Creating repositories requires a token with repository creation permission.
Before creating an issue, draft the title, body, and labels and ask for confirmation unless the user explicitly says to create it without confirmation.
## Create Repository
Use the bundled helper instead of hand-writing repository API calls:
```bash
python ~/.codex/skills/gitea-issues/scripts/create_repo.py \
--owner anodyine \
--name project-name \
--description "Short repository description"
```
The helper reads environment variables first, then infers the owner from the
current git remote or local env files, then falls back to the authenticated
Gitea user. It returns the existing repository URL if the repo already exists.
When publishing a local directory, initialize Git locally, commit the current
files, add the SSH remote returned by the user's Gitea host convention, and push
the default branch after the repository exists.
## Issue Format
Use this structure by default:
```markdown
## Problem
## Scope
## Acceptance Criteria
```
Prefer this label vocabulary:
- `status/needs-triage`
- `status/needs-info`
- `status/ready-for-agent`
- `status/ready-for-human`
- `status/in-progress`
- `status/review`
- `status/done`
- `status/wontfix`
- `type/epic`
- `type/feature`
- `type/bug`
- `type/refactor`
- `type/docs`
- `type/test`
- `type/architecture`
- `area/frontend`
- `area/backend`
- `area/docs`
- `area/tests`
- `area/agent-workflow`
- `area/workflow`
## Create Issue
Use the bundled helper instead of hand-writing API calls:
```bash
python ~/.codex/skills/gitea-issues/scripts/create_issue.py \
--title "Issue title" \
--body-file /path/to/body.md \
--label status/ready-for-agent \
--label type/docs
```
The helper reads environment variables first, then infers repo identity from the current git remote, then falls back to the local env files. It fetches repository labels, maps label names to numeric IDs, creates the issue, and prints the issue URL.
If requested labels do not exist in Gitea, stop and tell the user which labels are missing. Do not create the issue with a silently incomplete label set.
Default to grouping implementation issues under an epic. Before creating new
ready-for-agent work, either attach it to an existing focused epic or create a
small epic for the related batch. Only create a standalone issue when the user
explicitly asks for a one-off or no reasonable epic exists.
## Read Issue
Use the bundled read helper instead of hand-parsing API JSON:
```bash
python ~/.codex/skills/gitea-issues/scripts/read_issue.py --issue 5
```
Include comments when needed:
```bash
python ~/.codex/skills/gitea-issues/scripts/read_issue.py --issue 5 --comments
```
The helper prints a compact Markdown summary with issue metadata, labels, body, dependencies/blockers, and optional comments. Prefer this when using an issue as task input for coding work.
## Work the Next Ready Epic Issue
When asked to take the highest-priority ready task from an epic, treat labels as the agent-writeable source of truth. Gitea project columns are not available through this project's API. Use the epic body's `## Child Issues In Priority Order` list for priority, and select the first open child with `status/ready-for-agent`.
Use the workflow helper to find and claim the issue:
```bash
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py next-ready \
--epic 5 \
--claim
```
This prints the selected issue and moves it from `status/ready-for-agent` to `status/in-progress`. Then:
1. Read the selected issue with comments.
2. Create a branch named exactly `ISSUE-X`, where `X` is the issue number.
3. Attach that branch to the issue ref using `set-ref`.
4. Implement the requested change, including relevant tests and modular docs.
5. Run the issue-relevant automated tests.
6. Commit with a concise, logical message.
7. Push the branch.
8. Create a Gitea pull request.
9. Comment on the issue with the PR link.
10. Move the issue to `status/review`, not `status/done`.
11. Switch the local checkout back to `main` so the next agent starts new work from `main`.
The human reviewer marks the issue `status/done` after review and merge.
## YOLO Mode
Use YOLO mode only when the user explicitly asks for yolo/fully automated
issue work or when a DAG/workflow parameter clearly enables it. The normal
workflow above remains the default.
YOLO mode is for fast exploration where the agent should build, test, merge,
delete the branch, and mark the issue done so the user can review the merged
result later. It is appropriate for work the user has not yet deeply reviewed,
prototypes, small follow-ups, and low-risk changes where post-merge review is
acceptable.
In YOLO mode:
1. Claim the issue and move it to `status/in-progress`.
2. Create and attach the `ISSUE-X` branch as usual.
3. Implement the change, including relevant tests and docs.
4. Run the issue-relevant automated tests.
5. Commit and push the branch.
6. Create the pull request.
7. Merge the pull request immediately if it is mergeable.
8. Delete the merged branch unless the user asks to keep it.
9. Comment on the issue with the merged PR URL and verification summary.
10. Move the issue to `status/done`.
Never use YOLO mode for protected, ambiguous, security-sensitive, destructive,
or high-risk work unless the user explicitly accepts that risk. If tests fail,
the PR is not mergeable, or the implementation required major unplanned
decisions, stop at the normal review state instead of merging.
After creating the PR, use the helper to merge and close the loop:
```bash
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py yolo-merge \
--issue 6 \
--pull 12
```
By default this uses a merge commit and asks Gitea to delete the PR branch after
merge. To keep the branch:
```bash
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py yolo-merge \
--issue 6 \
--pull 12 \
--no-delete-branch
```
## Attach Branch or Tag Ref
After creating the issue branch, attach it to the issue so the Gitea sidebar does not show `No Branch/Tag Specified`:
```bash
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py set-ref \
--issue 6 \
--ref ISSUE-6
```
Use the exact branch name created for the issue. For the standard issue workflow, that branch is `ISSUE-X`, where `X` is the issue number.
## Update Issue Status
Use this helper instead of hand-writing label API calls:
```bash
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py set-status \
--issue 6 \
--status status/review
```
The helper adds the requested status label and removes other `status/*` labels from the issue.
## Create Pull Request and Link Issue
Write the PR body to a temporary Markdown file so shell quoting does not damage the requested format, then create the PR:
```bash
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py create-pr \
--head ISSUE-6 \
--base main \
--title "Add reusable desktop UI test harness" \
--body-file /tmp/pr-body.md
```
Use this PR body format when the user does not provide a different one:
````markdown
What did you change?
Why did you change it this way rather than using a different strategy or pattern?
Pre-Test Setup:
```bash
git fetch origin
git switch ISSUE-6
git pull --ff-only origin ISSUE-6
cd path/to/relevant/app-or-package
```
Manual Test Plan:
- [ ] Run the relevant test command.
```bash
command-to-run
```
- [ ] Open the changed UI or workflow.
- [ ] Interact with the changed behavior step by step, such as navigating to links, clicking controls, choosing files, typing in fields, selecting options, and checking visible feedback.
- [ ] Confirm the expected result and note any visual or interaction issues.
Post-Merge Cleanup:
```bash
git switch main
git pull origin main
git branch -d ISSUE-6
git push origin --delete ISSUE-6
```
````
The `Pre-Test Setup` block should use the actual PR branch name and relevant package path so the reviewer can paste it into a terminal before running tests. The manual test plan should contain only the verification steps and expected checks required to test the change. Include automated test commands when they are relevant, but also include human UI or workflow steps when the reviewer needs to inspect behavior directly: opening links, clicking controls, choosing files, typing in fields, selecting options, and checking visible feedback or layout. Commands intended to be copied and run should be in fenced `bash` blocks, including commands in the manual test plan. The `Post-Merge Cleanup` block should use the actual PR branch name so the reviewer can paste it into a terminal after merging. After creating the PR, comment on the issue and move it to review:
```bash
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py comment \
--issue 6 \
--body "Implemented in PR: <pull-request-url>"
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py set-status \
--issue 6 \
--status status/review
```
## Epic Issues
Gitea does not provide true nested epics in this project. Represent an epic as a normal issue plus dependency links:
- Create an epic issue with `type/epic` when available, or `type/architecture` if the label does not exist yet.
- List child issues in the epic body under `## Child Issues In Priority Order` so agents can use `next-ready --epic`.
- Attach each child issue as a dependency of the epic. Semantically, the epic is blocked until its child issues are complete.
- Prefer one focused epic per delivery goal, such as "Frontend testing foundation".
- When creating multiple follow-up issues, create the epic in the same turn and link the children before handing work to another agent.
Use the bundled dependency helper:
```bash
python ~/.codex/skills/gitea-issues/scripts/add_dependency.py \
--parent 5 \
--child 4
```
The parent is the blocked issue. The child is the issue it depends on.
Project boards are currently a manual UI step for this Gitea instance because its REST API does not expose project endpoints. Use labels and dependency links as the agent-writeable source of truth.
+4
View File
@@ -0,0 +1,4 @@
interface:
display_name: "Gitea Issues"
short_description: "Work Gitea issues through review or yolo merge"
default_prompt: "Use $gitea-issues to read, draft, create, claim, label, comment on, and work ready-for-agent Gitea issues through branch and pull request workflows, including the default human-review path or explicit yolo merge mode."
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Make one Gitea issue depend on another issue."""
from __future__ import annotations
import argparse
import json
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
from gitea_common import DEFAULT_ENV_FILE, config
def api_url(base_url: str, owner: str, repo: str, suffix: str) -> str:
encoded_owner = urllib.parse.quote(owner, safe="")
encoded_repo = urllib.parse.quote(repo, safe="")
return f"{base_url.rstrip('/')}/api/v1/repos/{encoded_owner}/{encoded_repo}/{suffix.lstrip('/')}"
def request_json(method: str, url: str, token: str, payload: dict[str, Any]) -> Any:
request = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), method=method)
request.add_header("Authorization", f"token {token}")
request.add_header("Accept", "application/json")
request.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(request, timeout=30) as response:
body = response.read().decode("utf-8")
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="replace")
raise SystemExit(f"Gitea API request failed: HTTP {exc.code}\n{error_body}") from exc
except urllib.error.URLError as exc:
raise SystemExit(f"Gitea API request failed: {exc.reason}") from exc
return json.loads(body) if body else None
def main() -> int:
parser = argparse.ArgumentParser(description="Make a Gitea issue depend on another issue.")
parser.add_argument("--parent", type=int, required=True, help="Issue number that is blocked.")
parser.add_argument("--child", type=int, required=True, help="Issue number the parent depends on.")
parser.add_argument("--child-owner", help="Owner of the child issue repo. Defaults to GITEA_OWNER.")
parser.add_argument("--child-repo", help="Repo of the child issue. Defaults to GITEA_REPO.")
parser.add_argument("--env-file", type=Path, default=DEFAULT_ENV_FILE)
args = parser.parse_args()
cfg = config(args.env_file.expanduser())
child_owner = args.child_owner or cfg["GITEA_OWNER"]
child_repo = args.child_repo or cfg["GITEA_REPO"]
issue = request_json(
"POST",
api_url(
cfg["GITEA_BASE_URL"],
cfg["GITEA_OWNER"],
cfg["GITEA_REPO"],
f"issues/{args.parent}/dependencies",
),
cfg["GITEA_TOKEN"],
{"owner": child_owner, "repo": child_repo, "index": args.child},
)
print(issue.get("html_url") or issue.get("url") or json.dumps(issue, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Create a Gitea issue using env vars or a local env file."""
from __future__ import annotations
import argparse
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
from gitea_common import DEFAULT_ENV_FILE, config
def request_json(method: str, url: str, token: str, payload: dict[str, Any] | None = None) -> Any:
data = None if payload is None else json.dumps(payload).encode("utf-8")
request = urllib.request.Request(url, data=data, method=method)
request.add_header("Authorization", f"token {token}")
request.add_header("Accept", "application/json")
if payload is not None:
request.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(request, timeout=30) as response:
body = response.read().decode("utf-8")
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="replace")
raise SystemExit(f"Gitea API request failed: HTTP {exc.code}\n{error_body}") from exc
except urllib.error.URLError as exc:
raise SystemExit(f"Gitea API request failed: {exc.reason}") from exc
return json.loads(body) if body else None
def api_url(base_url: str, owner: str, repo: str, suffix: str) -> str:
encoded_owner = urllib.parse.quote(owner, safe="")
encoded_repo = urllib.parse.quote(repo, safe="")
return f"{base_url.rstrip('/')}/api/v1/repos/{encoded_owner}/{encoded_repo}/{suffix.lstrip('/')}"
def get_label_ids(base_url: str, owner: str, repo: str, token: str, names: list[str]) -> list[int]:
if not names:
return []
labels = request_json("GET", api_url(base_url, owner, repo, "labels?limit=1000"), token)
by_name = {label["name"]: label["id"] for label in labels}
missing = [name for name in names if name not in by_name]
if missing:
raise SystemExit(
"Missing Gitea labels: "
+ ", ".join(missing)
+ "\nCreate those labels first, then rerun the issue creation."
)
return [by_name[name] for name in names]
def main() -> int:
parser = argparse.ArgumentParser(description="Create an issue in the configured Gitea repo.")
parser.add_argument("--title", required=True)
parser.add_argument("--body", default="")
parser.add_argument("--body-file", type=Path)
parser.add_argument("--label", action="append", default=[])
parser.add_argument("--env-file", type=Path, default=DEFAULT_ENV_FILE)
args = parser.parse_args()
cfg = config(args.env_file.expanduser())
body = args.body
if args.body_file:
body = args.body_file.read_text(encoding="utf-8")
label_ids = get_label_ids(
cfg["GITEA_BASE_URL"],
cfg["GITEA_OWNER"],
cfg["GITEA_REPO"],
cfg["GITEA_TOKEN"],
args.label,
)
issue = request_json(
"POST",
api_url(cfg["GITEA_BASE_URL"], cfg["GITEA_OWNER"], cfg["GITEA_REPO"], "issues"),
cfg["GITEA_TOKEN"],
{"title": args.title, "body": body, "labels": label_ids},
)
print(issue.get("html_url") or issue.get("url") or json.dumps(issue, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Create or return a Gitea repository using env vars or a local env file."""
from __future__ import annotations
import argparse
import json
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
from gitea_common import DEFAULT_ENV_FILE, auth_config
def request_json(method: str, url: str, token: str, payload: dict[str, Any] | None = None) -> Any:
data = None if payload is None else json.dumps(payload).encode("utf-8")
request = urllib.request.Request(url, data=data, method=method)
request.add_header("Authorization", f"token {token}")
request.add_header("Accept", "application/json")
if payload is not None:
request.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(request, timeout=30) as response:
body = response.read().decode("utf-8")
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="replace")
raise GiteaHTTPError(exc.code, error_body) from exc
except urllib.error.URLError as exc:
raise SystemExit(f"Gitea API request failed: {exc.reason}") from exc
return json.loads(body) if body else None
class GiteaHTTPError(Exception):
def __init__(self, status: int, body: str) -> None:
super().__init__(f"Gitea API request failed: HTTP {status}\n{body}")
self.status = status
self.body = body
def api_root(base_url: str) -> str:
return f"{base_url.rstrip('/')}/api/v1"
def repo_url(base_url: str, owner: str, repo: str) -> str:
encoded_owner = urllib.parse.quote(owner, safe="")
encoded_repo = urllib.parse.quote(repo, safe="")
return f"{api_root(base_url)}/repos/{encoded_owner}/{encoded_repo}"
def get_authenticated_user(base_url: str, token: str) -> dict[str, Any]:
return request_json("GET", f"{api_root(base_url)}/user", token)
def get_repo(base_url: str, token: str, owner: str, repo: str) -> dict[str, Any] | None:
try:
return request_json("GET", repo_url(base_url, owner, repo), token)
except GiteaHTTPError as exc:
if exc.status == 404:
return None
raise SystemExit(str(exc)) from exc
def create_repo(
base_url: str,
token: str,
owner: str,
repo: str,
description: str,
private: bool,
default_branch: str,
) -> dict[str, Any]:
user = get_authenticated_user(base_url, token)
login = user.get("login") or user.get("username") or ""
payload = {
"name": repo,
"description": description,
"private": private,
"auto_init": False,
"default_branch": default_branch,
}
if owner == login:
url = f"{api_root(base_url)}/user/repos"
else:
encoded_owner = urllib.parse.quote(owner, safe="")
url = f"{api_root(base_url)}/orgs/{encoded_owner}/repos"
try:
return request_json("POST", url, token, payload)
except GiteaHTTPError as exc:
if exc.status == 409:
existing = get_repo(base_url, token, owner, repo)
if existing:
return existing
raise SystemExit(str(exc)) from exc
def main() -> int:
parser = argparse.ArgumentParser(description="Create or return a Gitea repository.")
parser.add_argument("--name", required=True, help="Repository name to create.")
parser.add_argument("--owner", help="User or org owner. Defaults to GITEA_OWNER or authenticated user.")
parser.add_argument("--description", default="")
parser.add_argument("--private", action=argparse.BooleanOptionalAction, default=False)
parser.add_argument("--default-branch", default="main")
parser.add_argument("--env-file", type=Path, default=DEFAULT_ENV_FILE)
args = parser.parse_args()
cfg = auth_config(args.env_file.expanduser())
base_url = cfg["GITEA_BASE_URL"]
token = cfg["GITEA_TOKEN"]
owner = args.owner or cfg.get("GITEA_OWNER", "")
if not owner:
user = get_authenticated_user(base_url, token)
owner = user.get("login") or user.get("username") or ""
if not owner:
raise SystemExit("Could not determine Gitea owner. Pass --owner.")
repo = get_repo(base_url, token, owner, args.name)
if repo is None:
repo = create_repo(
base_url=base_url,
token=token,
owner=owner,
repo=args.name,
description=args.description,
private=args.private,
default_branch=args.default_branch,
)
print(repo.get("html_url") or repo.get("clone_url") or json.dumps(repo, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Shared Gitea configuration helpers for issue scripts."""
from __future__ import annotations
import os
import re
import shlex
import subprocess
from pathlib import Path
from urllib.parse import urlparse
DEFAULT_ENV_FILE = Path("~/.config/gitea/gitea.env").expanduser()
SECRETS_ENV_FILE = Path("~/.config/secrets/gitea.env").expanduser()
LEGACY_ENV_FILE = Path("~/.config/gitea/data-analysis-agent.env").expanduser()
REQUIRED_ENV = ("GITEA_BASE_URL", "GITEA_OWNER", "GITEA_REPO", "GITEA_TOKEN")
def load_env_file(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
if not path.exists():
return values
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[len("export ") :]
if "=" not in line:
continue
key, raw_value = line.split("=", 1)
key = key.strip()
try:
parsed = shlex.split(raw_value.strip(), comments=False, posix=True)
except ValueError as exc:
raise SystemExit(f"Could not parse {path}: {exc}") from exc
values[key] = parsed[0] if parsed else ""
return values
def discover_origin_config() -> dict[str, str]:
try:
remote = subprocess.run(
["git", "remote", "get-url", "origin"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
except (subprocess.CalledProcessError, FileNotFoundError):
return {}
if not remote:
return {}
parsed = urlparse(remote)
host = ""
path = ""
if parsed.scheme in {"http", "https", "ssh"} and parsed.netloc:
host = parsed.hostname or parsed.netloc.split("@")[-1].split(":")[0]
path = parsed.path.lstrip("/")
else:
match = re.match(r"(?:(?P<user>[^@]+)@)?(?P<host>[^:]+):(?P<path>.+)$", remote)
if match:
host = match.group("host")
path = match.group("path")
if not host or not path:
return {}
if path.endswith(".git"):
path = path[:-4]
parts = [part for part in path.split("/") if part]
if len(parts) < 2:
return {}
return {
"GITEA_BASE_URL": f"https://{host}",
"GITEA_OWNER": parts[-2],
"GITEA_REPO": parts[-1],
}
def config(env_file: Path | None = None) -> dict[str, str]:
env_path = (env_file or DEFAULT_ENV_FILE).expanduser()
file_values = load_env_file(env_path)
if env_path == DEFAULT_ENV_FILE:
secrets_values = load_env_file(SECRETS_ENV_FILE)
legacy_values = load_env_file(LEGACY_ENV_FILE)
file_values = {**legacy_values, **secrets_values, **file_values}
origin_values = discover_origin_config()
base_url = (
os.environ.get("GITEA_BASE_URL")
or os.environ.get("GITEA_URL")
or origin_values.get("GITEA_BASE_URL")
or file_values.get("GITEA_BASE_URL")
or file_values.get("GITEA_URL", "")
)
values = {
"GITEA_BASE_URL": base_url,
"GITEA_OWNER": os.environ.get("GITEA_OWNER")
or origin_values.get("GITEA_OWNER")
or file_values.get("GITEA_OWNER", ""),
"GITEA_REPO": os.environ.get("GITEA_REPO")
or origin_values.get("GITEA_REPO")
or file_values.get("GITEA_REPO", ""),
"GITEA_TOKEN": os.environ.get("GITEA_TOKEN") or file_values.get("GITEA_TOKEN", ""),
}
missing = [key for key, value in values.items() if not value]
if missing:
raise SystemExit(
"Missing required Gitea configuration: "
+ ", ".join(missing)
+ f"\nSet them in the shell, {env_path}, {SECRETS_ENV_FILE}, "
+ "or run from a git repo with a Gitea origin."
)
return values
def auth_config(env_file: Path | None = None) -> dict[str, str]:
"""Return Gitea API auth config without requiring a repository context."""
env_path = (env_file or DEFAULT_ENV_FILE).expanduser()
file_values = load_env_file(env_path)
if env_path == DEFAULT_ENV_FILE:
secrets_values = load_env_file(SECRETS_ENV_FILE)
legacy_values = load_env_file(LEGACY_ENV_FILE)
file_values = {**legacy_values, **secrets_values, **file_values}
origin_values = discover_origin_config()
base_url = (
os.environ.get("GITEA_BASE_URL")
or os.environ.get("GITEA_URL")
or origin_values.get("GITEA_BASE_URL")
or file_values.get("GITEA_BASE_URL")
or file_values.get("GITEA_URL", "")
)
values = {
"GITEA_BASE_URL": base_url,
"GITEA_OWNER": os.environ.get("GITEA_OWNER")
or origin_values.get("GITEA_OWNER")
or file_values.get("GITEA_OWNER", ""),
"GITEA_TOKEN": os.environ.get("GITEA_TOKEN") or file_values.get("GITEA_TOKEN", ""),
}
missing = [key for key in ("GITEA_BASE_URL", "GITEA_TOKEN") if not values[key]]
if missing:
raise SystemExit(
"Missing required Gitea configuration: "
+ ", ".join(missing)
+ f"\nSet them in the shell, {env_path}, {SECRETS_ENV_FILE}, "
+ "or run from a git repo with a Gitea origin."
)
return values
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""Gitea issue workflow helpers for ready-agent issue work."""
from __future__ import annotations
import argparse
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
from gitea_common import DEFAULT_ENV_FILE, config
STATUS_LABELS = {
"status/needs-triage",
"status/needs-info",
"status/ready-for-agent",
"status/ready-for-human",
"status/in-progress",
"status/review",
"status/done",
"status/wontfix",
}
def api_url(cfg: dict[str, str], suffix: str) -> str:
encoded_owner = urllib.parse.quote(cfg["GITEA_OWNER"], safe="")
encoded_repo = urllib.parse.quote(cfg["GITEA_REPO"], safe="")
return f"{cfg['GITEA_BASE_URL'].rstrip('/')}/api/v1/repos/{encoded_owner}/{encoded_repo}/{suffix.lstrip('/')}"
def request_json(method: str, cfg: dict[str, str], suffix: str, payload: dict[str, Any] | None = None) -> Any:
data = None if payload is None else json.dumps(payload).encode("utf-8")
request = urllib.request.Request(api_url(cfg, suffix), data=data, method=method)
request.add_header("Authorization", f"token {cfg['GITEA_TOKEN']}")
request.add_header("Accept", "application/json")
if payload is not None:
request.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(request, timeout=30) as response:
body = response.read().decode("utf-8")
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="replace")
raise GiteaError(exc.code, error_body) from exc
except urllib.error.URLError as exc:
raise SystemExit(f"Gitea API request failed: {exc.reason}") from exc
return json.loads(body) if body else None
class GiteaError(Exception):
def __init__(self, status: int, body: str) -> None:
super().__init__(f"Gitea API request failed: HTTP {status}\n{body}")
self.status = status
self.body = body
def labels_by_name(cfg: dict[str, str]) -> dict[str, dict[str, Any]]:
labels = request_json("GET", cfg, "labels?limit=1000")
return {label["name"]: label for label in labels}
def issue_labels(issue: dict[str, Any]) -> set[str]:
return {label["name"] for label in issue.get("labels", [])}
def read_issue(cfg: dict[str, str], issue_number: int) -> dict[str, Any]:
return request_json("GET", cfg, f"issues/{issue_number}")
def child_issue_numbers(epic_body: str) -> list[int]:
section_match = re.search(
r"^## Child Issues.*?(?=^## |\Z)",
epic_body,
flags=re.MULTILINE | re.DOTALL,
)
source = section_match.group(0) if section_match else epic_body
seen: set[int] = set()
numbers: list[int] = []
for match in re.finditer(r"#(\d+)", source):
number = int(match.group(1))
if number not in seen:
numbers.append(number)
seen.add(number)
return numbers
def set_status(cfg: dict[str, str], issue_number: int, status: str) -> dict[str, Any]:
if status not in STATUS_LABELS:
raise SystemExit(f"Unsupported status label: {status}")
labels = labels_by_name(cfg)
missing = [label for label in [status, *STATUS_LABELS] if label not in labels]
if missing:
raise SystemExit("Missing Gitea labels: " + ", ".join(sorted(missing)))
issue = read_issue(cfg, issue_number)
current = issue_labels(issue)
if status not in current:
request_json("POST", cfg, f"issues/{issue_number}/labels", {"labels": [labels[status]["id"]]})
for label in sorted(current & (STATUS_LABELS - {status})):
request_json("DELETE", cfg, f"issues/{issue_number}/labels/{labels[label]['id']}")
return read_issue(cfg, issue_number)
def set_ref(cfg: dict[str, str], issue_number: int, ref: str) -> dict[str, Any]:
return request_json("PATCH", cfg, f"issues/{issue_number}", {"ref": ref})
def print_issue_summary(issue: dict[str, Any]) -> None:
labels = ", ".join(sorted(issue_labels(issue))) or "(none)"
print(f"#{issue['number']} {issue['title']}")
print(f"URL: {issue['html_url']}")
print(f"Labels: {labels}")
print(f"Ref: {issue.get('ref') or '(none)'}")
def cmd_next_ready(args: argparse.Namespace) -> int:
cfg = config(args.env_file.expanduser())
epic = read_issue(cfg, args.epic)
child_numbers = child_issue_numbers(epic.get("body") or "")
for number in child_numbers:
issue = read_issue(cfg, number)
labels = issue_labels(issue)
if issue.get("state") == "open" and "status/ready-for-agent" in labels:
if args.claim:
issue = set_status(cfg, number, "status/in-progress")
print_issue_summary(issue)
return 0
print(f"No open child issue with status/ready-for-agent found under epic #{args.epic}.", file=sys.stderr)
return 2
def cmd_set_status(args: argparse.Namespace) -> int:
cfg = config(args.env_file.expanduser())
issue = set_status(cfg, args.issue, args.status)
print_issue_summary(issue)
return 0
def cmd_set_ref(args: argparse.Namespace) -> int:
cfg = config(args.env_file.expanduser())
issue = set_ref(cfg, args.issue, args.ref)
print_issue_summary(issue)
return 0
def cmd_comment(args: argparse.Namespace) -> int:
cfg = config(args.env_file.expanduser())
body = args.body_file.read_text(encoding="utf-8") if args.body_file else args.body
comment = request_json("POST", cfg, f"issues/{args.issue}/comments", {"body": body})
print(comment.get("html_url") or comment.get("url") or json.dumps(comment, indent=2))
return 0
def cmd_create_pr(args: argparse.Namespace) -> int:
cfg = config(args.env_file.expanduser())
body = args.body_file.read_text(encoding="utf-8") if args.body_file else args.body
payload = {"base": args.base, "head": args.head, "title": args.title, "body": body}
try:
pr = request_json("POST", cfg, "pulls", payload)
except GiteaError as exc:
if exc.status != 409:
raise SystemExit(str(exc)) from exc
owner = cfg["GITEA_OWNER"]
pulls = request_json("GET", cfg, f"pulls?state=open&head={owner}:{args.head}&base={args.base}") or []
if not pulls:
raise SystemExit(str(exc)) from exc
pr = pulls[0]
print(pr.get("html_url") or pr.get("url") or json.dumps(pr, indent=2))
return 0
def cmd_yolo_merge(args: argparse.Namespace) -> int:
cfg = config(args.env_file.expanduser())
pr = request_json("GET", cfg, f"pulls/{args.pull}")
if pr.get("state") != "open":
raise SystemExit(f"Pull request #{args.pull} is not open.")
if pr.get("merged"):
raise SystemExit(f"Pull request #{args.pull} is already merged.")
if pr.get("mergeable") is False:
raise SystemExit(f"Pull request #{args.pull} is not mergeable.")
payload = {
"Do": args.method,
"delete_branch_after_merge": args.delete_branch,
}
try:
request_json("POST", cfg, f"pulls/{args.pull}/merge", payload)
except GiteaError as exc:
raise SystemExit(str(exc)) from exc
pr_url = pr.get("html_url") or pr.get("url") or f"PR #{args.pull}"
comment_body = args.comment or f"YOLO mode merged {pr_url} after automated implementation and verification."
request_json("POST", cfg, f"issues/{args.issue}/comments", {"body": comment_body})
issue = set_status(cfg, args.issue, "status/done")
print_issue_summary(issue)
print(f"Merged PR: {pr_url}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Work with the configured Gitea issue workflow.")
parser.add_argument("--env-file", type=Path, default=DEFAULT_ENV_FILE)
subparsers = parser.add_subparsers(dest="command", required=True)
next_ready = subparsers.add_parser("next-ready", help="Find the highest-priority ready child issue in an epic.")
next_ready.add_argument("--epic", type=int, required=True)
next_ready.add_argument("--claim", action="store_true", help="Move the selected issue to status/in-progress.")
next_ready.set_defaults(func=cmd_next_ready)
set_status_parser = subparsers.add_parser("set-status", help="Replace an issue status label.")
set_status_parser.add_argument("--issue", type=int, required=True)
set_status_parser.add_argument("--status", required=True)
set_status_parser.set_defaults(func=cmd_set_status)
set_ref_parser = subparsers.add_parser("set-ref", help="Attach a branch or tag ref to an issue.")
set_ref_parser.add_argument("--issue", type=int, required=True)
set_ref_parser.add_argument("--ref", required=True)
set_ref_parser.set_defaults(func=cmd_set_ref)
comment = subparsers.add_parser("comment", help="Add an issue comment.")
comment.add_argument("--issue", type=int, required=True)
comment.add_argument("--body", default="")
comment.add_argument("--body-file", type=Path)
comment.set_defaults(func=cmd_comment)
create_pr = subparsers.add_parser("create-pr", help="Create or return an existing open pull request.")
create_pr.add_argument("--head", required=True)
create_pr.add_argument("--base", default="main")
create_pr.add_argument("--title", required=True)
create_pr.add_argument("--body", default="")
create_pr.add_argument("--body-file", type=Path)
create_pr.set_defaults(func=cmd_create_pr)
yolo_merge = subparsers.add_parser(
"yolo-merge",
help="Merge a PR immediately, optionally delete its branch, comment, and mark the issue done.",
)
yolo_merge.add_argument("--issue", type=int, required=True)
yolo_merge.add_argument("--pull", type=int, required=True)
yolo_merge.add_argument(
"--method",
default="merge",
choices=("merge", "rebase", "rebase-merge", "squash"),
help="Gitea merge method.",
)
yolo_merge.add_argument(
"--delete-branch",
action=argparse.BooleanOptionalAction,
default=True,
help="Delete the PR branch after merge.",
)
yolo_merge.add_argument("--comment", default="")
yolo_merge.set_defaults(func=cmd_yolo_merge)
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Read a Gitea issue and print a compact Markdown summary."""
from __future__ import annotations
import argparse
import json
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
from gitea_common import DEFAULT_ENV_FILE, config
def api_url(base_url: str, owner: str, repo: str, suffix: str) -> str:
encoded_owner = urllib.parse.quote(owner, safe="")
encoded_repo = urllib.parse.quote(repo, safe="")
return f"{base_url.rstrip('/')}/api/v1/repos/{encoded_owner}/{encoded_repo}/{suffix.lstrip('/')}"
def request_json(method: str, url: str, token: str) -> Any:
request = urllib.request.Request(url, method=method)
request.add_header("Authorization", f"token {token}")
request.add_header("Accept", "application/json")
try:
with urllib.request.urlopen(request, timeout=30) as response:
body = response.read().decode("utf-8")
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="replace")
raise SystemExit(f"Gitea API request failed: HTTP {exc.code}\n{error_body}") from exc
except urllib.error.URLError as exc:
raise SystemExit(f"Gitea API request failed: {exc.reason}") from exc
return json.loads(body) if body else None
def issue_line(issue: dict[str, Any]) -> str:
return f"#{issue['number']} {issue['title']} ({issue['state']}) - {issue['html_url']}"
def print_issue(issue: dict[str, Any], dependencies: list[dict[str, Any]], comments: list[dict[str, Any]] | None) -> None:
labels = [label["name"] for label in issue.get("labels", [])]
print(f"# Issue #{issue['number']}: {issue['title']}")
print()
print(f"- URL: {issue['html_url']}")
print(f"- State: {issue['state']}")
print(f"- Labels: {', '.join(labels) if labels else '(none)'}")
print(f"- Created: {issue.get('created_at')}")
print(f"- Updated: {issue.get('updated_at')}")
print()
print("## Body")
print()
print(issue.get("body") or "(empty)")
print()
print("## Dependencies")
print()
if dependencies:
for dependency in sorted(dependencies, key=lambda item: item["number"]):
print(f"- {issue_line(dependency)}")
else:
print("(none)")
if comments is not None:
print()
print("## Comments")
print()
if comments:
for comment in comments:
user = comment.get("user", {}).get("login", "unknown")
print(f"### Comment by {user} at {comment.get('created_at')}")
print()
print(comment.get("body") or "(empty)")
print()
else:
print("(none)")
def main() -> int:
parser = argparse.ArgumentParser(description="Read an issue in the configured Gitea repo.")
parser.add_argument("--issue", type=int, required=True)
parser.add_argument("--comments", action="store_true", help="Include issue comments.")
parser.add_argument("--env-file", type=Path, default=DEFAULT_ENV_FILE)
args = parser.parse_args()
cfg = config(args.env_file.expanduser())
issue = request_json(
"GET",
api_url(cfg["GITEA_BASE_URL"], cfg["GITEA_OWNER"], cfg["GITEA_REPO"], f"issues/{args.issue}"),
cfg["GITEA_TOKEN"],
)
dependencies = request_json(
"GET",
api_url(
cfg["GITEA_BASE_URL"],
cfg["GITEA_OWNER"],
cfg["GITEA_REPO"],
f"issues/{args.issue}/dependencies?limit=1000",
),
cfg["GITEA_TOKEN"],
)
comments = None
if args.comments:
comments = request_json(
"GET",
api_url(
cfg["GITEA_BASE_URL"],
cfg["GITEA_OWNER"],
cfg["GITEA_REPO"],
f"issues/{args.issue}/comments?limit=1000",
),
cfg["GITEA_TOKEN"],
)
print_issue(issue, dependencies, comments)
return 0
if __name__ == "__main__":
raise SystemExit(main())