Publish training skills collection
This commit is contained in:
Executable
+70
@@ -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())
|
||||
Executable
+93
@@ -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())
|
||||
Executable
+138
@@ -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())
|
||||
@@ -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
|
||||
Executable
+265
@@ -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())
|
||||
Executable
+123
@@ -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())
|
||||
Reference in New Issue
Block a user