From c27b85193f2fbf3a6745b9fff52f6bdf52c01da8 Mon Sep 17 00:00:00 2001 From: zfc Date: Thu, 12 Mar 2026 17:23:08 +0800 Subject: [PATCH] Add unified Gitea skill workflows --- .agents/skills/gitea/SKILL.md | 144 ++++++++ .agents/skills/gitea/scripts/common.py | 334 ++++++++++++++++++ .agents/skills/gitea/scripts/pr_gitea.py | 166 +++++++++ .agents/skills/gitea/scripts/push_gitea.py | 236 +++++++++++++ .agents/skills/issue-drive/SKILL.md | 12 +- .../scripts/create_gitea_issues.py | 5 +- .agents/skills/issue/SKILL.md | 4 +- .claude/skills/gitea/SKILL.md | 144 ++++++++ .claude/skills/gitea/scripts/common.py | 334 ++++++++++++++++++ .claude/skills/gitea/scripts/pr_gitea.py | 166 +++++++++ .claude/skills/gitea/scripts/push_gitea.py | 236 +++++++++++++ .claude/skills/issue-drive/SKILL.md | 12 +- .../scripts/create_gitea_issues.py | 5 +- .claude/skills/issue/SKILL.md | 4 +- .codex/skills/AGENTS.md.template | 1 + .codex/skills/gitea/SKILL.md | 144 ++++++++ .codex/skills/gitea/scripts/common.py | 334 ++++++++++++++++++ .codex/skills/gitea/scripts/pr_gitea.py | 166 +++++++++ .codex/skills/gitea/scripts/push_gitea.py | 236 +++++++++++++ .codex/skills/issue-drive/SKILL.md | 12 +- .../scripts/create_gitea_issues.py | 5 +- .codex/skills/issue/SKILL.md | 4 +- .gitignore | 10 + AGENTS.md | 1 + AGENTS.md.template | 1 + README.md | 57 ++- 26 files changed, 2725 insertions(+), 48 deletions(-) create mode 100644 .agents/skills/gitea/SKILL.md create mode 100644 .agents/skills/gitea/scripts/common.py create mode 100644 .agents/skills/gitea/scripts/pr_gitea.py create mode 100644 .agents/skills/gitea/scripts/push_gitea.py create mode 100644 .claude/skills/gitea/SKILL.md create mode 100644 .claude/skills/gitea/scripts/common.py create mode 100644 .claude/skills/gitea/scripts/pr_gitea.py create mode 100644 .claude/skills/gitea/scripts/push_gitea.py create mode 100644 .codex/skills/gitea/SKILL.md create mode 100644 .codex/skills/gitea/scripts/common.py create mode 100644 .codex/skills/gitea/scripts/pr_gitea.py create mode 100644 .codex/skills/gitea/scripts/push_gitea.py diff --git a/.agents/skills/gitea/SKILL.md b/.agents/skills/gitea/SKILL.md new file mode 100644 index 0000000..9cfab45 --- /dev/null +++ b/.agents/skills/gitea/SKILL.md @@ -0,0 +1,144 @@ +--- +name: gitea +description: 统一 Gitea 总入口,支持 issue 查询、issue 拆单创建、git push 和 PR 基础操作,优先从当前仓库 origin 自动识别目标仓库。 +--- + +# Gitea - 统一入口 + +> **定位**:这是 Gitea 总入口。适合“看 issue”“拆 issue”“把提交 push 上去”“给当前分支开 PR”“在 PR 里留言”这类场景。 +> +> `issue` 和 `issue-drive` 继续保留:`issue` 负责只读查看,`issue-drive` 负责拆单和创建工单;`gitea` 负责统一路由和 push / PR 能力。 + +当用户调用 `/gitea`、`$gitea`,或自然语言要求“处理 Gitea / push / PR / issue”时,按以下规则执行。 + +## 1. 先路由意图 + +先从用户输入里判断目标动作: + +- 包含“看 / 查 / list / open / closed / #123 / issue”,且没有“拆 / 建 / 提 / 创建工单”语义:走 `issue` +- 包含“拆 issue / 提 issue / 创建工单 / 沉淀问题”语义:走 `issue-drive` +- 包含“push / 推送 / tag 推上去 / 推当前分支”语义:走 push 流程 +- 包含“PR / pull request / 拉请求 / 合并请求 / 评论 PR”语义:走 PR 流程 + +如果一句话里同时包含多个动作,先按用户描述顺序执行;默认不要自作主张串更多步骤。 + +## 2. 环境变量 + +统一使用: + +- `GITEA_TOKEN`:必需 +- `GITEA_BASE_URL`:可选;当传 `owner/repo` 或当前仓库 `origin` 是 SSH 地址时推荐配置 + +如果缺少 `GITEA_TOKEN`,先提示用户配置后停止,不继续执行 API、push fallback 或 PR 操作。 + +## 3. Issue 路由 + +如果判定为只读 issue: + +- 直接执行 `issue` skill 的流程 +- 保持它的输入规则:当前仓库自动识别、支持 `owner/repo`、支持完整仓库 URL、支持单条 issue 编号 +- 不在 `gitea` 里重复抄写 `issue` 的长说明 + +如果判定为 issue 拆单 / 创建: + +- 直接执行 `issue-drive` 的流程 +- 先整理事实和证据,再创建 issue +- 创建时统一使用 `GITEA_TOKEN` + +## 4. Push 流程 + +Push 一律先跑预检,再决定是否执行: + +```bash +# Codex +python3 .codex/skills/gitea/scripts/push_gitea.py + +# Claude Code +python3 .claude/skills/gitea/scripts/push_gitea.py +``` + +规则: + +- 默认推当前分支到同名远端分支 +- `ahead=0` 时直接告诉用户“没有可推送提交” +- `dirty=true` 时明确提示“只会推送已提交内容” +- 分支 diverged 时停止,不自动 rebase,也不自动 force +- 只有用户明确要求“强推 / force push”时,才带 `--force` + +真正执行时: + +```bash +# Codex +python3 .codex/skills/gitea/scripts/push_gitea.py --execute + +# Claude Code +python3 .claude/skills/gitea/scripts/push_gitea.py --execute +``` + +若用户明确要求强推: + +```bash +python3 .codex/skills/gitea/scripts/push_gitea.py --execute --force +``` + +执行策略: + +- 先尝试 `git push origin branch:branch` +- 若远端鉴权失败,再使用 `GITEA_TOKEN` 调 `/api/v1/user` 获取登录名,构造一次性 HTTPS token URL 进行 fallback +- 不改写本地 `origin` + +## 5. PR 流程 + +PR 只做三件事:`list`、`create`、`comment`。 + +### 5.1 看 PR + +```bash +# Codex +python3 .codex/skills/gitea/scripts/pr_gitea.py list --state=open --limit=20 +``` + +### 5.2 开 PR + +```bash +# Codex +python3 .codex/skills/gitea/scripts/pr_gitea.py create --base main --title "标题" --body "正文" +``` + +规则: + +- `head` 默认当前分支 +- `base` 默认远端默认分支;识别不到时再显式传 `--base` +- 如果当前分支还没推送到远端,脚本会先走 push 流程,再创建 PR +- v1 只支持同仓库 PR,不处理 fork 间 PR + +### 5.3 在 PR 里留言 + +```bash +# Codex +python3 .codex/skills/gitea/scripts/pr_gitea.py comment 12 --body "已完成回归" +``` + +规则: + +- PR 评论走 issue comment API,因为 Gitea PR 底层复用 issue 线程 + +## 6. 输出要求 + +- issue 查询:沿用 `issue` skill 的固定 Markdown 输出 +- issue 创建:沿用 `issue-drive` 的创建结果输出 +- push:输出预检 JSON 或执行结果 JSON,至少包含 `status`、`branch`、`ahead/behind`、是否执行、执行方式 +- PR:输出 PR 列表或单条创建 / 评论结果 JSON,至少包含编号、标题、URL、状态 + +## 7. 用法示例 + +```bash +/gitea 看当前仓库 open issues +/gitea 查 issue 17 +/gitea 把这个 bug 拆成两张 issue +/gitea push +/gitea 推送当前分支到远端 +/gitea 给当前分支开 PR 到 main +/gitea 看当前仓库 open PR +/gitea 在 PR 12 留言:已完成回归 +``` diff --git a/.agents/skills/gitea/scripts/common.py b/.agents/skills/gitea/scripts/common.py new file mode 100644 index 0000000..1ef2240 --- /dev/null +++ b/.agents/skills/gitea/scripts/common.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""Shared helpers for Gitea skill scripts.""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass + +SSH_RE = re.compile(r"^git@(?P[^:]+):(?P.+?)(?:\.git)?/?$") +REPO_PATH_RE = re.compile(r"^(?P[^/]+)/(?P[^/]+)$") + + +class GitCommandError(RuntimeError): + """Raised when a git command fails.""" + + +@dataclass +class RepoContext: + origin: str + owner: str + repo: str + repo_url: str + remote_name: str = "origin" + remote_url: str | None = None + + +def normalize_base_url(base_url: str) -> str: + return base_url.rstrip("/") + + +def ensure_git_repo() -> None: + result = subprocess.run( + ["git", "rev-parse", "--is-inside-work-tree"], + capture_output=True, + text=True, + ) + if result.returncode != 0 or result.stdout.strip() != "true": + raise SystemExit("Current directory is not a git repository.") + + +def run_git(args: list[str], cwd: str | None = None, check: bool = True) -> str: + result = subprocess.run( + ["git", *args], + capture_output=True, + text=True, + cwd=cwd, + ) + if check and result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or "unknown git error" + raise GitCommandError(f"git {' '.join(args)} failed: {detail}") + return result.stdout.strip() + + +def parse_http_repo_url(repo_url: str) -> tuple[str, str, str, str]: + parsed = urllib.parse.urlsplit(repo_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise SystemExit("Invalid repo URL. Expected format: https://host/owner/repo") + + path = parsed.path.rstrip("/") + if path.endswith(".git"): + path = path[:-4] + + parts = [part for part in path.split("/") if part] + if len(parts) < 2: + raise SystemExit("Invalid repo URL. Expected format: https://host/owner/repo") + + owner, repo = parts[-2], parts[-1] + prefix = "/".join(parts[:-2]) + origin = f"{parsed.scheme}://{parsed.netloc}" + if prefix: + origin = f"{origin}/{prefix}" + normalized_repo_url = f"{origin}/{owner}/{repo}" + return origin, owner, repo, normalized_repo_url + + +def parse_repo_target( + repo_target: str, + base_url: str | None = None, +) -> tuple[str, str, str, str]: + value = repo_target.strip() + if not value: + raise SystemExit("Repo target cannot be empty.") + + if value.startswith("http://") or value.startswith("https://"): + return parse_http_repo_url(value) + + if value.startswith("ssh://"): + parsed = urllib.parse.urlsplit(value) + path = parsed.path.rstrip("/") + if path.endswith(".git"): + path = path[:-4] + parts = [part for part in path.split("/") if part] + if len(parts) < 2: + raise SystemExit( + "Invalid SSH repo URL. Expected ssh://git@host/owner/repo.git" + ) + owner, repo = parts[-2], parts[-1] + prefix = "/".join(parts[:-2]) + host = parsed.hostname + if not host: + raise SystemExit("Invalid SSH repo URL. Missing host.") + origin = ( + normalize_base_url(base_url) + if base_url + else f"https://{host}{f'/{prefix}' if prefix else ''}" + ) + return origin, owner, repo, f"{origin}/{owner}/{repo}" + + ssh_match = SSH_RE.match(value) + if ssh_match: + path = ssh_match.group("path").rstrip("/") + if path.endswith(".git"): + path = path[:-4] + parts = [part for part in path.split("/") if part] + if len(parts) < 2: + raise SystemExit( + "Invalid SSH repo target. Expected git@host:owner/repo.git" + ) + owner, repo = parts[-2], parts[-1] + prefix = "/".join(parts[:-2]) + origin = ( + normalize_base_url(base_url) + if base_url + else f"https://{ssh_match.group('host')}{f'/{prefix}' if prefix else ''}" + ) + return origin, owner, repo, f"{origin}/{owner}/{repo}" + + path_match = REPO_PATH_RE.match(value) + if path_match: + if not base_url: + raise SystemExit( + "Repo shorthand owner/repo requires GITEA_BASE_URL or a full repo URL." + ) + origin = normalize_base_url(base_url) + owner = path_match.group("owner") + repo = path_match.group("repo") + return origin, owner, repo, f"{origin}/{owner}/{repo}" + + raise SystemExit( + "Invalid repo target. Use https://host/owner/repo, git@host:owner/repo.git, " + "ssh://git@host/owner/repo.git, or owner/repo with GITEA_BASE_URL." + ) + + +def get_remote_url(remote: str = "origin") -> str: + ensure_git_repo() + try: + return run_git(["remote", "get-url", remote]) + except GitCommandError as exc: + raise SystemExit(f"Failed to read git remote '{remote}': {exc}") from exc + + +def resolve_repo( + repo_url: str | None = None, + repo: str | None = None, + remote: str = "origin", +) -> RepoContext: + base_url = os.getenv("GITEA_BASE_URL") + remote_url = None + target = repo_url or repo + if not target: + remote_url = get_remote_url(remote) + target = remote_url + origin, owner, repo_name, normalized_repo_url = parse_repo_target( + target, + base_url=base_url, + ) + return RepoContext( + origin=origin, + owner=owner, + repo=repo_name, + repo_url=normalized_repo_url, + remote_name=remote, + remote_url=remote_url, + ) + + +def api_base(context: RepoContext) -> str: + return f"{context.origin}/api/v1/repos/{context.owner}/{context.repo}" + + +def load_token(required: bool = True) -> str: + token = os.getenv("GITEA_TOKEN", "").strip() + if not token and required: + raise SystemExit("Missing GITEA_TOKEN. Export it before using Gitea workflows.") + return token + + +def request_json( + url: str, + token: str, + method: str = "GET", + payload: dict | None = None, +) -> dict | list: + data = None + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = f"token {token}" + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + + request = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(request) as response: + return json.load(response) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise SystemExit( + f"Gitea API request failed: {exc.code} {exc.reason} | {body}" + ) from exc + except urllib.error.URLError as exc: + raise SystemExit(f"Failed to reach Gitea API: {exc.reason}") from exc + + +def get_current_user_login(origin: str, token: str) -> str: + data = request_json(f"{origin}/api/v1/user", token) + if not isinstance(data, dict): + raise SystemExit("Unexpected user API response.") + login = str(data.get("login") or "").strip() + if not login: + raise SystemExit("Failed to resolve current Gitea user login.") + return login + + +def current_branch() -> str: + ensure_git_repo() + try: + branch = run_git(["symbolic-ref", "--quiet", "--short", "HEAD"]) + except GitCommandError as exc: + raise SystemExit( + "Detached HEAD. Checkout a branch before running push or PR actions." + ) from exc + if not branch: + raise SystemExit("Failed to determine current git branch.") + return branch + + +def get_upstream(branch: str) -> str | None: + result = subprocess.run( + [ + "git", + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + f"{branch}@{{upstream}}", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + upstream = result.stdout.strip() + return upstream or None + + +def remote_branch_sha(remote: str, branch: str) -> str | None: + try: + output = run_git(["ls-remote", "--heads", remote, branch]) + except GitCommandError as exc: + raise SystemExit(f"Failed to query remote branch '{remote}/{branch}': {exc}") from exc + if not output: + return None + return output.split()[0] + + +def ahead_behind(compare_ref: str) -> tuple[int, int]: + output = run_git(["rev-list", "--left-right", "--count", f"{compare_ref}...HEAD"]) + parts = output.split() + if len(parts) != 2: + raise SystemExit(f"Unexpected rev-list output: {output}") + behind, ahead = (int(part) for part in parts) + return behind, ahead + + +def worktree_changes() -> list[str]: + output = run_git(["status", "--short"]) + return [line for line in output.splitlines() if line.strip()] + + +def remote_default_branch(remote: str = "origin") -> str: + try: + output = run_git(["symbolic-ref", "--quiet", "--short", f"refs/remotes/{remote}/HEAD"]) + if output.startswith(f"{remote}/"): + return output.split("/", 1)[1] + except GitCommandError: + pass + + for candidate in ("main", "master"): + if remote_branch_sha(remote, candidate): + return candidate + raise SystemExit( + f"Failed to infer default branch for remote '{remote}'. Pass --base explicitly." + ) + + +def build_authenticated_push_url(repo_url: str, username: str, token: str) -> str: + parsed = urllib.parse.urlsplit(repo_url) + path = parsed.path.rstrip("/") + if not path.endswith(".git"): + path = f"{path}.git" + netloc = ( + f"{urllib.parse.quote(username, safe='')}:" + f"{urllib.parse.quote(token, safe='')}@{parsed.netloc}" + ) + return urllib.parse.urlunsplit((parsed.scheme or "https", netloc, path, "", "")) + + +def mask_url(url: str) -> str: + parsed = urllib.parse.urlsplit(url) + if "@" not in parsed.netloc: + return url + _, host = parsed.netloc.rsplit("@", 1) + return urllib.parse.urlunsplit((parsed.scheme, f"***@{host}", parsed.path, "", "")) + + +def is_auth_error(stderr: str) -> bool: + message = stderr.lower() + indicators = ( + "authentication failed", + "permission denied", + "could not read username", + "could not read password", + "http basic: access denied", + "access denied", + "unauthorized", + ) + return any(indicator in message for indicator in indicators) diff --git a/.agents/skills/gitea/scripts/pr_gitea.py b/.agents/skills/gitea/scripts/pr_gitea.py new file mode 100644 index 0000000..a3f2e96 --- /dev/null +++ b/.agents/skills/gitea/scripts/pr_gitea.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""List, create, and comment on Gitea pull requests.""" + +from __future__ import annotations + +import argparse +import json +import sys + +from common import ( + api_base, + current_branch, + load_token, + remote_default_branch, + request_json, + resolve_repo, +) +from push_gitea import compute_push_plan, execute_push + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="List, create, and comment on Gitea pull requests." + ) + parser.add_argument("--repo-url", help="Explicit target repo URL.") + parser.add_argument("--repo", help="Shorthand owner/repo. Requires GITEA_BASE_URL.") + parser.add_argument("--remote", default="origin", help="Git remote name. Default: origin") + + subparsers = parser.add_subparsers(dest="command", required=True) + + list_parser = subparsers.add_parser("list", help="List pull requests.") + list_parser.add_argument( + "--state", + default="open", + choices=("open", "closed", "all"), + help="Pull request state filter.", + ) + list_parser.add_argument( + "--limit", + default=20, + type=int, + help="Maximum number of pull requests to return.", + ) + + create_parser = subparsers.add_parser("create", help="Create a pull request.") + create_parser.add_argument("--base", help="Base branch. Default: remote default branch.") + create_parser.add_argument("--head", help="Head branch. Default: current branch.") + create_parser.add_argument("--title", required=True, help="Pull request title.") + create_parser.add_argument("--body", default="", help="Pull request body.") + + comment_parser = subparsers.add_parser("comment", help="Comment on a pull request.") + comment_parser.add_argument("pr", type=int, help="Pull request number.") + comment_parser.add_argument("--body", required=True, help="Comment body.") + + return parser.parse_args() + + +def summarize_pull_request(item: dict) -> dict: + user = item.get("user") or {} + head = item.get("head") or {} + base = item.get("base") or {} + return { + "number": item.get("number"), + "title": item.get("title"), + "state": item.get("state"), + "html_url": item.get("html_url"), + "author": user.get("full_name") or user.get("login"), + "head": head.get("ref"), + "base": base.get("ref"), + "created_at": item.get("created_at"), + "updated_at": item.get("updated_at"), + } + + +def list_pull_requests(args: argparse.Namespace) -> dict: + context = resolve_repo(repo_url=args.repo_url, repo=args.repo, remote=args.remote) + token = load_token(required=True) + url = f"{api_base(context)}/pulls?state={args.state}&page=1&limit={args.limit}" + data = request_json(url, token) + if not isinstance(data, list): + raise SystemExit("Unexpected pull request list response.") + return { + "action": "list", + "repo_url": context.repo_url, + "state": args.state, + "limit": args.limit, + "count": len(data), + "pull_requests": [summarize_pull_request(item) for item in data], + } + + +def create_pull_request(args: argparse.Namespace) -> dict: + context = resolve_repo(repo_url=args.repo_url, repo=args.repo, remote=args.remote) + token = load_token(required=True) + head = args.head or current_branch() + base = args.base or remote_default_branch(args.remote) + + push_plan = compute_push_plan( + repo_url=args.repo_url, + repo=args.repo, + remote=args.remote, + branch=head, + force=False, + ) + push_result = None + if push_plan["needs_push"]: + push_result = execute_push(push_plan, force=False) + elif push_plan["status"] == "blocked": + raise SystemExit( + "Cannot create PR because the current branch has diverged from the remote branch." + ) + + payload = {"base": base, "head": head, "title": args.title, "body": args.body} + created = request_json(f"{api_base(context)}/pulls", token, method="POST", payload=payload) + if not isinstance(created, dict): + raise SystemExit("Unexpected pull request creation response.") + return { + "action": "create", + "repo_url": context.repo_url, + "base": base, + "head": head, + "push": push_result, + "pull_request": summarize_pull_request(created), + } + + +def comment_pull_request(args: argparse.Namespace) -> dict: + context = resolve_repo(repo_url=args.repo_url, repo=args.repo, remote=args.remote) + token = load_token(required=True) + payload = {"body": args.body} + created = request_json( + f"{api_base(context)}/issues/{args.pr}/comments", + token, + method="POST", + payload=payload, + ) + if not isinstance(created, dict): + raise SystemExit("Unexpected PR comment response.") + user = created.get("user") or {} + return { + "action": "comment", + "repo_url": context.repo_url, + "pull_request": args.pr, + "comment": { + "id": created.get("id"), + "html_url": created.get("html_url"), + "author": user.get("full_name") or user.get("login"), + "created_at": created.get("created_at"), + }, + } + + +def main() -> int: + args = parse_args() + if args.command == "list": + payload = list_pull_requests(args) + elif args.command == "create": + payload = create_pull_request(args) + else: + payload = comment_pull_request(args) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/gitea/scripts/push_gitea.py b/.agents/skills/gitea/scripts/push_gitea.py new file mode 100644 index 0000000..6b4b9cf --- /dev/null +++ b/.agents/skills/gitea/scripts/push_gitea.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Preflight and execute git push with optional Gitea token fallback.""" + +from __future__ import annotations + +import argparse +import json +import shlex +import subprocess +import sys + +from common import ( + build_authenticated_push_url, + current_branch, + ensure_git_repo, + get_current_user_login, + get_upstream, + is_auth_error, + load_token, + mask_url, + remote_branch_sha, + resolve_repo, + worktree_changes, + ahead_behind, +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Preflight and optionally push the current branch to Gitea." + ) + parser.add_argument("--repo-url", help="Explicit target repo URL.") + parser.add_argument("--repo", help="Shorthand owner/repo. Requires GITEA_BASE_URL.") + parser.add_argument("--remote", default="origin", help="Git remote name. Default: origin") + parser.add_argument("--branch", help="Branch to push. Default: current branch") + parser.add_argument( + "--execute", + action="store_true", + help="Run git push after preflight instead of only printing the plan.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Allow force push. Uses --force-with-lease under the hood.", + ) + return parser.parse_args() + + +def build_push_command(target: str, branch: str, force: bool) -> list[str]: + command = ["git", "push"] + if force: + command.append("--force-with-lease") + command.extend([target, f"{branch}:{branch}"]) + return command + + +def command_text(command: list[str]) -> str: + return " ".join(shlex.quote(part) for part in command) + + +def compute_push_plan( + repo_url: str | None = None, + repo: str | None = None, + remote: str = "origin", + branch: str | None = None, + force: bool = False, +) -> dict: + ensure_git_repo() + context = resolve_repo(repo_url=repo_url, repo=repo, remote=remote) + branch_name = branch or current_branch() + dirty_lines = worktree_changes() + upstream = get_upstream(branch_name) + compare_ref = upstream + remote_exists = True + ahead = 0 + behind = 0 + + if upstream: + behind, ahead = ahead_behind(upstream) + else: + remote_sha = remote_branch_sha(remote, branch_name) + if remote_sha: + compare_ref = f"{remote}/{branch_name}" + behind, ahead = ahead_behind(remote_sha) + else: + remote_exists = False + compare_ref = None + ahead = None + behind = 0 + + diverged = bool(remote_exists and behind > 0 and (ahead or 0) > 0) + behind_only = bool(remote_exists and behind > 0 and (ahead or 0) == 0) + needs_push = (not remote_exists) or ((ahead or 0) > 0) + + status = "ready" + if diverged and not force: + status = "blocked" + elif not needs_push: + status = "noop" + + messages: list[str] = [] + if dirty_lines: + messages.append("Working tree is dirty; push will include only committed changes.") + if not remote_exists: + messages.append("Remote branch does not exist yet; push will create it.") + if behind_only: + messages.append("Local branch is behind the remote branch; there are no local commits to push.") + if diverged and not force: + messages.append("Branch has diverged from remote; rerun with explicit force only if that is intended.") + + push_command = build_push_command(remote, branch_name, force) + fallback_command = None + token_available = bool(load_token(required=False)) + if token_available: + try: + token = load_token(required=False) + login = get_current_user_login(context.origin, token) + auth_url = build_authenticated_push_url(context.repo_url, login, token) + fallback_command = build_push_command(auth_url, branch_name, force) + except SystemExit as exc: + messages.append(f"Failed to prepare HTTPS token fallback: {exc}") + + return { + "repo_url": context.repo_url, + "origin": context.origin, + "owner": context.owner, + "repo": context.repo, + "remote": remote, + "branch": branch_name, + "upstream": upstream, + "compare_ref": compare_ref, + "remote_branch_exists": remote_exists, + "dirty": bool(dirty_lines), + "dirty_paths": dirty_lines, + "ahead": ahead, + "behind": behind, + "diverged": diverged, + "behind_only": behind_only, + "needs_push": needs_push, + "status": status, + "messages": messages, + "push_command": command_text(push_command), + "fallback_push_command": command_text( + [ + *(fallback_command[:-2] if fallback_command else []), + mask_url(fallback_command[-2]) if fallback_command else "", + *(fallback_command[-1:] if fallback_command else []), + ] + ) + if fallback_command + else None, + } + + +def run_command(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, capture_output=True, text=True) + + +def execute_push(plan: dict, force: bool = False) -> dict: + if plan["status"] == "noop": + return { + "status": "noop", + "executed": False, + "reason": "nothing_to_push", + "messages": plan["messages"], + } + if plan["diverged"] and not force: + raise SystemExit( + "Branch has diverged from remote. Refusing to push without explicit --force." + ) + + primary_command = build_push_command(plan["remote"], plan["branch"], force) + primary_result = run_command(primary_command) + if primary_result.returncode == 0: + return { + "status": "pushed", + "executed": True, + "method": "remote", + "command": command_text(primary_command), + "stdout": primary_result.stdout.strip(), + "stderr": primary_result.stderr.strip(), + } + + primary_stderr = primary_result.stderr.strip() or primary_result.stdout.strip() + if not is_auth_error(primary_stderr): + raise SystemExit(f"git push failed: {primary_stderr}") + + token = load_token(required=True) + login = get_current_user_login(plan["origin"], token) + auth_url = build_authenticated_push_url(plan["repo_url"], login, token) + fallback_command = build_push_command(auth_url, plan["branch"], force) + fallback_result = run_command(fallback_command) + if fallback_result.returncode == 0: + return { + "status": "pushed", + "executed": True, + "method": "https-token", + "command": command_text( + [ + *(fallback_command[:-2]), + mask_url(fallback_command[-2]), + fallback_command[-1], + ] + ), + "stdout": fallback_result.stdout.strip(), + "stderr": fallback_result.stderr.strip(), + } + + fallback_stderr = fallback_result.stderr.strip() or fallback_result.stdout.strip() + raise SystemExit( + "git push failed with remote auth and HTTPS token fallback: " + f"{fallback_stderr}" + ) + + +def main() -> int: + args = parse_args() + plan = compute_push_plan( + repo_url=args.repo_url, + repo=args.repo, + remote=args.remote, + branch=args.branch, + force=args.force, + ) + if not args.execute: + print(json.dumps(plan, ensure_ascii=False, indent=2)) + return 0 + + result = execute_push(plan, force=args.force) + payload = {"plan": plan, "result": result} + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/issue-drive/SKILL.md b/.agents/skills/issue-drive/SKILL.md index 9a4a401..fa7a2d3 100644 --- a/.agents/skills/issue-drive/SKILL.md +++ b/.agents/skills/issue-drive/SKILL.md @@ -7,7 +7,7 @@ description: 归集证据并把问题拆成 1 到多张 Gitea issue,支持从 > **定位**:不是“查看 issue”,而是把一个真实问题沉淀成可执行的 Gitea issue。适合“线上出 bug 了”“要把一个需求拆成工单”“需要补齐工程治理任务”这类场景。 > -> 如果用户只是想查 issue 列表或详情,用 `issue`。 +> 如果用户只是想查 issue 列表或详情,用 `issue`。如果用户想通过统一入口完成 issue / push / PR 组合操作,用 `gitea`。 当用户调用 `/issue-drive`、`$issue-drive`,或自然语言要求“拆 issue”“提 issue”“把问题沉淀成 Gitea 工单”时,执行以下步骤。 @@ -45,17 +45,15 @@ export GITEA_BASE_URL=https://git.example.com 先读取环境变量: -- `GITEA_ISSUE`:创建 issue 时优先使用 -- `GITEA_TOKEN`:`GITEA_ISSUE` 缺失时回退使用 +- `GITEA_TOKEN`:必需,创建 issue 时使用 - `GITEA_BASE_URL`:可选;仓库简写或 SSH origin 时推荐配置 -如果 `GITEA_ISSUE` 和 `GITEA_TOKEN` 都缺失,输出: +如果缺少 `GITEA_TOKEN`,输出: ```bash -❌ 缺少 GITEA_ISSUE / GITEA_TOKEN +❌ 缺少 GITEA_TOKEN 请先在当前 shell 或 .env 中配置: -export GITEA_ISSUE=your_gitea_write_token export GITEA_TOKEN=your_gitea_token ``` @@ -151,7 +149,7 @@ JSON 结构固定为: - `repo_url` 可省略;脚本会按 `--repo-url` > `spec.repo_url` > `spec.repo` > 当前仓库 `origin` 的顺序解析 - `spec.repo` 允许写 `owner/repo`,但需要 `GITEA_BASE_URL` -- Token 优先读取 `GITEA_ISSUE`,兼容回退 `GITEA_TOKEN` +- Token 固定读取 `GITEA_TOKEN` - `labels` 写标签名,脚本会自动解析成远端 label id - 远端不存在的标签会告警并自动跳过,不中断整个批次 - 先跑 `--dry-run`,确认 payload 和目标仓库无误后再正式创建 diff --git a/.agents/skills/issue-drive/scripts/create_gitea_issues.py b/.agents/skills/issue-drive/scripts/create_gitea_issues.py index 00fbb2f..963bd4e 100644 --- a/.agents/skills/issue-drive/scripts/create_gitea_issues.py +++ b/.agents/skills/issue-drive/scripts/create_gitea_issues.py @@ -43,11 +43,10 @@ def parse_args() -> argparse.Namespace: def load_token(required: bool = True) -> str: - token = os.getenv("GITEA_ISSUE") or os.getenv("GITEA_TOKEN") + token = os.getenv("GITEA_TOKEN") if not token and required: raise SystemExit( - "Missing GITEA_ISSUE (or fallback GITEA_TOKEN). " - "Export the token before creating issues." + "Missing GITEA_TOKEN. Export the token before creating issues." ) return token or "" diff --git a/.agents/skills/issue/SKILL.md b/.agents/skills/issue/SKILL.md index 781052d..e726e3d 100644 --- a/.agents/skills/issue/SKILL.md +++ b/.agents/skills/issue/SKILL.md @@ -5,9 +5,9 @@ description: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条 # Issue - 通用 Gitea Issue 查看 -> **定位**:这是只读 skill。用于查看当前仓库或指定仓库的 issue 列表、单条详情和评论,不负责拆单和创建工单。 +> **定位**:这是只读 skill。用于查看当前仓库或指定仓库的 issue 列表、单条详情和评论,不负责拆单、创建工单、push 或 PR。 > -> 如果用户要做的是“把问题拆成 issue 并实际创建工单”,不要使用本 skill,改用 `issue-drive`。 +> 如果用户要做的是“把问题拆成 issue 并实际创建工单”,改用 `issue-drive`。如果用户想用统一入口处理 Gitea 相关任务,改用 `gitea`。 当用户调用 `/issue`、`$issue`,或自然语言要求“查看当前仓库 issue”“看某个 Gitea 仓库的 issue”“查 issue #17”时,执行以下步骤。 diff --git a/.claude/skills/gitea/SKILL.md b/.claude/skills/gitea/SKILL.md new file mode 100644 index 0000000..9cfab45 --- /dev/null +++ b/.claude/skills/gitea/SKILL.md @@ -0,0 +1,144 @@ +--- +name: gitea +description: 统一 Gitea 总入口,支持 issue 查询、issue 拆单创建、git push 和 PR 基础操作,优先从当前仓库 origin 自动识别目标仓库。 +--- + +# Gitea - 统一入口 + +> **定位**:这是 Gitea 总入口。适合“看 issue”“拆 issue”“把提交 push 上去”“给当前分支开 PR”“在 PR 里留言”这类场景。 +> +> `issue` 和 `issue-drive` 继续保留:`issue` 负责只读查看,`issue-drive` 负责拆单和创建工单;`gitea` 负责统一路由和 push / PR 能力。 + +当用户调用 `/gitea`、`$gitea`,或自然语言要求“处理 Gitea / push / PR / issue”时,按以下规则执行。 + +## 1. 先路由意图 + +先从用户输入里判断目标动作: + +- 包含“看 / 查 / list / open / closed / #123 / issue”,且没有“拆 / 建 / 提 / 创建工单”语义:走 `issue` +- 包含“拆 issue / 提 issue / 创建工单 / 沉淀问题”语义:走 `issue-drive` +- 包含“push / 推送 / tag 推上去 / 推当前分支”语义:走 push 流程 +- 包含“PR / pull request / 拉请求 / 合并请求 / 评论 PR”语义:走 PR 流程 + +如果一句话里同时包含多个动作,先按用户描述顺序执行;默认不要自作主张串更多步骤。 + +## 2. 环境变量 + +统一使用: + +- `GITEA_TOKEN`:必需 +- `GITEA_BASE_URL`:可选;当传 `owner/repo` 或当前仓库 `origin` 是 SSH 地址时推荐配置 + +如果缺少 `GITEA_TOKEN`,先提示用户配置后停止,不继续执行 API、push fallback 或 PR 操作。 + +## 3. Issue 路由 + +如果判定为只读 issue: + +- 直接执行 `issue` skill 的流程 +- 保持它的输入规则:当前仓库自动识别、支持 `owner/repo`、支持完整仓库 URL、支持单条 issue 编号 +- 不在 `gitea` 里重复抄写 `issue` 的长说明 + +如果判定为 issue 拆单 / 创建: + +- 直接执行 `issue-drive` 的流程 +- 先整理事实和证据,再创建 issue +- 创建时统一使用 `GITEA_TOKEN` + +## 4. Push 流程 + +Push 一律先跑预检,再决定是否执行: + +```bash +# Codex +python3 .codex/skills/gitea/scripts/push_gitea.py + +# Claude Code +python3 .claude/skills/gitea/scripts/push_gitea.py +``` + +规则: + +- 默认推当前分支到同名远端分支 +- `ahead=0` 时直接告诉用户“没有可推送提交” +- `dirty=true` 时明确提示“只会推送已提交内容” +- 分支 diverged 时停止,不自动 rebase,也不自动 force +- 只有用户明确要求“强推 / force push”时,才带 `--force` + +真正执行时: + +```bash +# Codex +python3 .codex/skills/gitea/scripts/push_gitea.py --execute + +# Claude Code +python3 .claude/skills/gitea/scripts/push_gitea.py --execute +``` + +若用户明确要求强推: + +```bash +python3 .codex/skills/gitea/scripts/push_gitea.py --execute --force +``` + +执行策略: + +- 先尝试 `git push origin branch:branch` +- 若远端鉴权失败,再使用 `GITEA_TOKEN` 调 `/api/v1/user` 获取登录名,构造一次性 HTTPS token URL 进行 fallback +- 不改写本地 `origin` + +## 5. PR 流程 + +PR 只做三件事:`list`、`create`、`comment`。 + +### 5.1 看 PR + +```bash +# Codex +python3 .codex/skills/gitea/scripts/pr_gitea.py list --state=open --limit=20 +``` + +### 5.2 开 PR + +```bash +# Codex +python3 .codex/skills/gitea/scripts/pr_gitea.py create --base main --title "标题" --body "正文" +``` + +规则: + +- `head` 默认当前分支 +- `base` 默认远端默认分支;识别不到时再显式传 `--base` +- 如果当前分支还没推送到远端,脚本会先走 push 流程,再创建 PR +- v1 只支持同仓库 PR,不处理 fork 间 PR + +### 5.3 在 PR 里留言 + +```bash +# Codex +python3 .codex/skills/gitea/scripts/pr_gitea.py comment 12 --body "已完成回归" +``` + +规则: + +- PR 评论走 issue comment API,因为 Gitea PR 底层复用 issue 线程 + +## 6. 输出要求 + +- issue 查询:沿用 `issue` skill 的固定 Markdown 输出 +- issue 创建:沿用 `issue-drive` 的创建结果输出 +- push:输出预检 JSON 或执行结果 JSON,至少包含 `status`、`branch`、`ahead/behind`、是否执行、执行方式 +- PR:输出 PR 列表或单条创建 / 评论结果 JSON,至少包含编号、标题、URL、状态 + +## 7. 用法示例 + +```bash +/gitea 看当前仓库 open issues +/gitea 查 issue 17 +/gitea 把这个 bug 拆成两张 issue +/gitea push +/gitea 推送当前分支到远端 +/gitea 给当前分支开 PR 到 main +/gitea 看当前仓库 open PR +/gitea 在 PR 12 留言:已完成回归 +``` diff --git a/.claude/skills/gitea/scripts/common.py b/.claude/skills/gitea/scripts/common.py new file mode 100644 index 0000000..1ef2240 --- /dev/null +++ b/.claude/skills/gitea/scripts/common.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""Shared helpers for Gitea skill scripts.""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass + +SSH_RE = re.compile(r"^git@(?P[^:]+):(?P.+?)(?:\.git)?/?$") +REPO_PATH_RE = re.compile(r"^(?P[^/]+)/(?P[^/]+)$") + + +class GitCommandError(RuntimeError): + """Raised when a git command fails.""" + + +@dataclass +class RepoContext: + origin: str + owner: str + repo: str + repo_url: str + remote_name: str = "origin" + remote_url: str | None = None + + +def normalize_base_url(base_url: str) -> str: + return base_url.rstrip("/") + + +def ensure_git_repo() -> None: + result = subprocess.run( + ["git", "rev-parse", "--is-inside-work-tree"], + capture_output=True, + text=True, + ) + if result.returncode != 0 or result.stdout.strip() != "true": + raise SystemExit("Current directory is not a git repository.") + + +def run_git(args: list[str], cwd: str | None = None, check: bool = True) -> str: + result = subprocess.run( + ["git", *args], + capture_output=True, + text=True, + cwd=cwd, + ) + if check and result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or "unknown git error" + raise GitCommandError(f"git {' '.join(args)} failed: {detail}") + return result.stdout.strip() + + +def parse_http_repo_url(repo_url: str) -> tuple[str, str, str, str]: + parsed = urllib.parse.urlsplit(repo_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise SystemExit("Invalid repo URL. Expected format: https://host/owner/repo") + + path = parsed.path.rstrip("/") + if path.endswith(".git"): + path = path[:-4] + + parts = [part for part in path.split("/") if part] + if len(parts) < 2: + raise SystemExit("Invalid repo URL. Expected format: https://host/owner/repo") + + owner, repo = parts[-2], parts[-1] + prefix = "/".join(parts[:-2]) + origin = f"{parsed.scheme}://{parsed.netloc}" + if prefix: + origin = f"{origin}/{prefix}" + normalized_repo_url = f"{origin}/{owner}/{repo}" + return origin, owner, repo, normalized_repo_url + + +def parse_repo_target( + repo_target: str, + base_url: str | None = None, +) -> tuple[str, str, str, str]: + value = repo_target.strip() + if not value: + raise SystemExit("Repo target cannot be empty.") + + if value.startswith("http://") or value.startswith("https://"): + return parse_http_repo_url(value) + + if value.startswith("ssh://"): + parsed = urllib.parse.urlsplit(value) + path = parsed.path.rstrip("/") + if path.endswith(".git"): + path = path[:-4] + parts = [part for part in path.split("/") if part] + if len(parts) < 2: + raise SystemExit( + "Invalid SSH repo URL. Expected ssh://git@host/owner/repo.git" + ) + owner, repo = parts[-2], parts[-1] + prefix = "/".join(parts[:-2]) + host = parsed.hostname + if not host: + raise SystemExit("Invalid SSH repo URL. Missing host.") + origin = ( + normalize_base_url(base_url) + if base_url + else f"https://{host}{f'/{prefix}' if prefix else ''}" + ) + return origin, owner, repo, f"{origin}/{owner}/{repo}" + + ssh_match = SSH_RE.match(value) + if ssh_match: + path = ssh_match.group("path").rstrip("/") + if path.endswith(".git"): + path = path[:-4] + parts = [part for part in path.split("/") if part] + if len(parts) < 2: + raise SystemExit( + "Invalid SSH repo target. Expected git@host:owner/repo.git" + ) + owner, repo = parts[-2], parts[-1] + prefix = "/".join(parts[:-2]) + origin = ( + normalize_base_url(base_url) + if base_url + else f"https://{ssh_match.group('host')}{f'/{prefix}' if prefix else ''}" + ) + return origin, owner, repo, f"{origin}/{owner}/{repo}" + + path_match = REPO_PATH_RE.match(value) + if path_match: + if not base_url: + raise SystemExit( + "Repo shorthand owner/repo requires GITEA_BASE_URL or a full repo URL." + ) + origin = normalize_base_url(base_url) + owner = path_match.group("owner") + repo = path_match.group("repo") + return origin, owner, repo, f"{origin}/{owner}/{repo}" + + raise SystemExit( + "Invalid repo target. Use https://host/owner/repo, git@host:owner/repo.git, " + "ssh://git@host/owner/repo.git, or owner/repo with GITEA_BASE_URL." + ) + + +def get_remote_url(remote: str = "origin") -> str: + ensure_git_repo() + try: + return run_git(["remote", "get-url", remote]) + except GitCommandError as exc: + raise SystemExit(f"Failed to read git remote '{remote}': {exc}") from exc + + +def resolve_repo( + repo_url: str | None = None, + repo: str | None = None, + remote: str = "origin", +) -> RepoContext: + base_url = os.getenv("GITEA_BASE_URL") + remote_url = None + target = repo_url or repo + if not target: + remote_url = get_remote_url(remote) + target = remote_url + origin, owner, repo_name, normalized_repo_url = parse_repo_target( + target, + base_url=base_url, + ) + return RepoContext( + origin=origin, + owner=owner, + repo=repo_name, + repo_url=normalized_repo_url, + remote_name=remote, + remote_url=remote_url, + ) + + +def api_base(context: RepoContext) -> str: + return f"{context.origin}/api/v1/repos/{context.owner}/{context.repo}" + + +def load_token(required: bool = True) -> str: + token = os.getenv("GITEA_TOKEN", "").strip() + if not token and required: + raise SystemExit("Missing GITEA_TOKEN. Export it before using Gitea workflows.") + return token + + +def request_json( + url: str, + token: str, + method: str = "GET", + payload: dict | None = None, +) -> dict | list: + data = None + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = f"token {token}" + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + + request = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(request) as response: + return json.load(response) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise SystemExit( + f"Gitea API request failed: {exc.code} {exc.reason} | {body}" + ) from exc + except urllib.error.URLError as exc: + raise SystemExit(f"Failed to reach Gitea API: {exc.reason}") from exc + + +def get_current_user_login(origin: str, token: str) -> str: + data = request_json(f"{origin}/api/v1/user", token) + if not isinstance(data, dict): + raise SystemExit("Unexpected user API response.") + login = str(data.get("login") or "").strip() + if not login: + raise SystemExit("Failed to resolve current Gitea user login.") + return login + + +def current_branch() -> str: + ensure_git_repo() + try: + branch = run_git(["symbolic-ref", "--quiet", "--short", "HEAD"]) + except GitCommandError as exc: + raise SystemExit( + "Detached HEAD. Checkout a branch before running push or PR actions." + ) from exc + if not branch: + raise SystemExit("Failed to determine current git branch.") + return branch + + +def get_upstream(branch: str) -> str | None: + result = subprocess.run( + [ + "git", + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + f"{branch}@{{upstream}}", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + upstream = result.stdout.strip() + return upstream or None + + +def remote_branch_sha(remote: str, branch: str) -> str | None: + try: + output = run_git(["ls-remote", "--heads", remote, branch]) + except GitCommandError as exc: + raise SystemExit(f"Failed to query remote branch '{remote}/{branch}': {exc}") from exc + if not output: + return None + return output.split()[0] + + +def ahead_behind(compare_ref: str) -> tuple[int, int]: + output = run_git(["rev-list", "--left-right", "--count", f"{compare_ref}...HEAD"]) + parts = output.split() + if len(parts) != 2: + raise SystemExit(f"Unexpected rev-list output: {output}") + behind, ahead = (int(part) for part in parts) + return behind, ahead + + +def worktree_changes() -> list[str]: + output = run_git(["status", "--short"]) + return [line for line in output.splitlines() if line.strip()] + + +def remote_default_branch(remote: str = "origin") -> str: + try: + output = run_git(["symbolic-ref", "--quiet", "--short", f"refs/remotes/{remote}/HEAD"]) + if output.startswith(f"{remote}/"): + return output.split("/", 1)[1] + except GitCommandError: + pass + + for candidate in ("main", "master"): + if remote_branch_sha(remote, candidate): + return candidate + raise SystemExit( + f"Failed to infer default branch for remote '{remote}'. Pass --base explicitly." + ) + + +def build_authenticated_push_url(repo_url: str, username: str, token: str) -> str: + parsed = urllib.parse.urlsplit(repo_url) + path = parsed.path.rstrip("/") + if not path.endswith(".git"): + path = f"{path}.git" + netloc = ( + f"{urllib.parse.quote(username, safe='')}:" + f"{urllib.parse.quote(token, safe='')}@{parsed.netloc}" + ) + return urllib.parse.urlunsplit((parsed.scheme or "https", netloc, path, "", "")) + + +def mask_url(url: str) -> str: + parsed = urllib.parse.urlsplit(url) + if "@" not in parsed.netloc: + return url + _, host = parsed.netloc.rsplit("@", 1) + return urllib.parse.urlunsplit((parsed.scheme, f"***@{host}", parsed.path, "", "")) + + +def is_auth_error(stderr: str) -> bool: + message = stderr.lower() + indicators = ( + "authentication failed", + "permission denied", + "could not read username", + "could not read password", + "http basic: access denied", + "access denied", + "unauthorized", + ) + return any(indicator in message for indicator in indicators) diff --git a/.claude/skills/gitea/scripts/pr_gitea.py b/.claude/skills/gitea/scripts/pr_gitea.py new file mode 100644 index 0000000..a3f2e96 --- /dev/null +++ b/.claude/skills/gitea/scripts/pr_gitea.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""List, create, and comment on Gitea pull requests.""" + +from __future__ import annotations + +import argparse +import json +import sys + +from common import ( + api_base, + current_branch, + load_token, + remote_default_branch, + request_json, + resolve_repo, +) +from push_gitea import compute_push_plan, execute_push + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="List, create, and comment on Gitea pull requests." + ) + parser.add_argument("--repo-url", help="Explicit target repo URL.") + parser.add_argument("--repo", help="Shorthand owner/repo. Requires GITEA_BASE_URL.") + parser.add_argument("--remote", default="origin", help="Git remote name. Default: origin") + + subparsers = parser.add_subparsers(dest="command", required=True) + + list_parser = subparsers.add_parser("list", help="List pull requests.") + list_parser.add_argument( + "--state", + default="open", + choices=("open", "closed", "all"), + help="Pull request state filter.", + ) + list_parser.add_argument( + "--limit", + default=20, + type=int, + help="Maximum number of pull requests to return.", + ) + + create_parser = subparsers.add_parser("create", help="Create a pull request.") + create_parser.add_argument("--base", help="Base branch. Default: remote default branch.") + create_parser.add_argument("--head", help="Head branch. Default: current branch.") + create_parser.add_argument("--title", required=True, help="Pull request title.") + create_parser.add_argument("--body", default="", help="Pull request body.") + + comment_parser = subparsers.add_parser("comment", help="Comment on a pull request.") + comment_parser.add_argument("pr", type=int, help="Pull request number.") + comment_parser.add_argument("--body", required=True, help="Comment body.") + + return parser.parse_args() + + +def summarize_pull_request(item: dict) -> dict: + user = item.get("user") or {} + head = item.get("head") or {} + base = item.get("base") or {} + return { + "number": item.get("number"), + "title": item.get("title"), + "state": item.get("state"), + "html_url": item.get("html_url"), + "author": user.get("full_name") or user.get("login"), + "head": head.get("ref"), + "base": base.get("ref"), + "created_at": item.get("created_at"), + "updated_at": item.get("updated_at"), + } + + +def list_pull_requests(args: argparse.Namespace) -> dict: + context = resolve_repo(repo_url=args.repo_url, repo=args.repo, remote=args.remote) + token = load_token(required=True) + url = f"{api_base(context)}/pulls?state={args.state}&page=1&limit={args.limit}" + data = request_json(url, token) + if not isinstance(data, list): + raise SystemExit("Unexpected pull request list response.") + return { + "action": "list", + "repo_url": context.repo_url, + "state": args.state, + "limit": args.limit, + "count": len(data), + "pull_requests": [summarize_pull_request(item) for item in data], + } + + +def create_pull_request(args: argparse.Namespace) -> dict: + context = resolve_repo(repo_url=args.repo_url, repo=args.repo, remote=args.remote) + token = load_token(required=True) + head = args.head or current_branch() + base = args.base or remote_default_branch(args.remote) + + push_plan = compute_push_plan( + repo_url=args.repo_url, + repo=args.repo, + remote=args.remote, + branch=head, + force=False, + ) + push_result = None + if push_plan["needs_push"]: + push_result = execute_push(push_plan, force=False) + elif push_plan["status"] == "blocked": + raise SystemExit( + "Cannot create PR because the current branch has diverged from the remote branch." + ) + + payload = {"base": base, "head": head, "title": args.title, "body": args.body} + created = request_json(f"{api_base(context)}/pulls", token, method="POST", payload=payload) + if not isinstance(created, dict): + raise SystemExit("Unexpected pull request creation response.") + return { + "action": "create", + "repo_url": context.repo_url, + "base": base, + "head": head, + "push": push_result, + "pull_request": summarize_pull_request(created), + } + + +def comment_pull_request(args: argparse.Namespace) -> dict: + context = resolve_repo(repo_url=args.repo_url, repo=args.repo, remote=args.remote) + token = load_token(required=True) + payload = {"body": args.body} + created = request_json( + f"{api_base(context)}/issues/{args.pr}/comments", + token, + method="POST", + payload=payload, + ) + if not isinstance(created, dict): + raise SystemExit("Unexpected PR comment response.") + user = created.get("user") or {} + return { + "action": "comment", + "repo_url": context.repo_url, + "pull_request": args.pr, + "comment": { + "id": created.get("id"), + "html_url": created.get("html_url"), + "author": user.get("full_name") or user.get("login"), + "created_at": created.get("created_at"), + }, + } + + +def main() -> int: + args = parse_args() + if args.command == "list": + payload = list_pull_requests(args) + elif args.command == "create": + payload = create_pull_request(args) + else: + payload = comment_pull_request(args) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/skills/gitea/scripts/push_gitea.py b/.claude/skills/gitea/scripts/push_gitea.py new file mode 100644 index 0000000..6b4b9cf --- /dev/null +++ b/.claude/skills/gitea/scripts/push_gitea.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Preflight and execute git push with optional Gitea token fallback.""" + +from __future__ import annotations + +import argparse +import json +import shlex +import subprocess +import sys + +from common import ( + build_authenticated_push_url, + current_branch, + ensure_git_repo, + get_current_user_login, + get_upstream, + is_auth_error, + load_token, + mask_url, + remote_branch_sha, + resolve_repo, + worktree_changes, + ahead_behind, +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Preflight and optionally push the current branch to Gitea." + ) + parser.add_argument("--repo-url", help="Explicit target repo URL.") + parser.add_argument("--repo", help="Shorthand owner/repo. Requires GITEA_BASE_URL.") + parser.add_argument("--remote", default="origin", help="Git remote name. Default: origin") + parser.add_argument("--branch", help="Branch to push. Default: current branch") + parser.add_argument( + "--execute", + action="store_true", + help="Run git push after preflight instead of only printing the plan.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Allow force push. Uses --force-with-lease under the hood.", + ) + return parser.parse_args() + + +def build_push_command(target: str, branch: str, force: bool) -> list[str]: + command = ["git", "push"] + if force: + command.append("--force-with-lease") + command.extend([target, f"{branch}:{branch}"]) + return command + + +def command_text(command: list[str]) -> str: + return " ".join(shlex.quote(part) for part in command) + + +def compute_push_plan( + repo_url: str | None = None, + repo: str | None = None, + remote: str = "origin", + branch: str | None = None, + force: bool = False, +) -> dict: + ensure_git_repo() + context = resolve_repo(repo_url=repo_url, repo=repo, remote=remote) + branch_name = branch or current_branch() + dirty_lines = worktree_changes() + upstream = get_upstream(branch_name) + compare_ref = upstream + remote_exists = True + ahead = 0 + behind = 0 + + if upstream: + behind, ahead = ahead_behind(upstream) + else: + remote_sha = remote_branch_sha(remote, branch_name) + if remote_sha: + compare_ref = f"{remote}/{branch_name}" + behind, ahead = ahead_behind(remote_sha) + else: + remote_exists = False + compare_ref = None + ahead = None + behind = 0 + + diverged = bool(remote_exists and behind > 0 and (ahead or 0) > 0) + behind_only = bool(remote_exists and behind > 0 and (ahead or 0) == 0) + needs_push = (not remote_exists) or ((ahead or 0) > 0) + + status = "ready" + if diverged and not force: + status = "blocked" + elif not needs_push: + status = "noop" + + messages: list[str] = [] + if dirty_lines: + messages.append("Working tree is dirty; push will include only committed changes.") + if not remote_exists: + messages.append("Remote branch does not exist yet; push will create it.") + if behind_only: + messages.append("Local branch is behind the remote branch; there are no local commits to push.") + if diverged and not force: + messages.append("Branch has diverged from remote; rerun with explicit force only if that is intended.") + + push_command = build_push_command(remote, branch_name, force) + fallback_command = None + token_available = bool(load_token(required=False)) + if token_available: + try: + token = load_token(required=False) + login = get_current_user_login(context.origin, token) + auth_url = build_authenticated_push_url(context.repo_url, login, token) + fallback_command = build_push_command(auth_url, branch_name, force) + except SystemExit as exc: + messages.append(f"Failed to prepare HTTPS token fallback: {exc}") + + return { + "repo_url": context.repo_url, + "origin": context.origin, + "owner": context.owner, + "repo": context.repo, + "remote": remote, + "branch": branch_name, + "upstream": upstream, + "compare_ref": compare_ref, + "remote_branch_exists": remote_exists, + "dirty": bool(dirty_lines), + "dirty_paths": dirty_lines, + "ahead": ahead, + "behind": behind, + "diverged": diverged, + "behind_only": behind_only, + "needs_push": needs_push, + "status": status, + "messages": messages, + "push_command": command_text(push_command), + "fallback_push_command": command_text( + [ + *(fallback_command[:-2] if fallback_command else []), + mask_url(fallback_command[-2]) if fallback_command else "", + *(fallback_command[-1:] if fallback_command else []), + ] + ) + if fallback_command + else None, + } + + +def run_command(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, capture_output=True, text=True) + + +def execute_push(plan: dict, force: bool = False) -> dict: + if plan["status"] == "noop": + return { + "status": "noop", + "executed": False, + "reason": "nothing_to_push", + "messages": plan["messages"], + } + if plan["diverged"] and not force: + raise SystemExit( + "Branch has diverged from remote. Refusing to push without explicit --force." + ) + + primary_command = build_push_command(plan["remote"], plan["branch"], force) + primary_result = run_command(primary_command) + if primary_result.returncode == 0: + return { + "status": "pushed", + "executed": True, + "method": "remote", + "command": command_text(primary_command), + "stdout": primary_result.stdout.strip(), + "stderr": primary_result.stderr.strip(), + } + + primary_stderr = primary_result.stderr.strip() or primary_result.stdout.strip() + if not is_auth_error(primary_stderr): + raise SystemExit(f"git push failed: {primary_stderr}") + + token = load_token(required=True) + login = get_current_user_login(plan["origin"], token) + auth_url = build_authenticated_push_url(plan["repo_url"], login, token) + fallback_command = build_push_command(auth_url, plan["branch"], force) + fallback_result = run_command(fallback_command) + if fallback_result.returncode == 0: + return { + "status": "pushed", + "executed": True, + "method": "https-token", + "command": command_text( + [ + *(fallback_command[:-2]), + mask_url(fallback_command[-2]), + fallback_command[-1], + ] + ), + "stdout": fallback_result.stdout.strip(), + "stderr": fallback_result.stderr.strip(), + } + + fallback_stderr = fallback_result.stderr.strip() or fallback_result.stdout.strip() + raise SystemExit( + "git push failed with remote auth and HTTPS token fallback: " + f"{fallback_stderr}" + ) + + +def main() -> int: + args = parse_args() + plan = compute_push_plan( + repo_url=args.repo_url, + repo=args.repo, + remote=args.remote, + branch=args.branch, + force=args.force, + ) + if not args.execute: + print(json.dumps(plan, ensure_ascii=False, indent=2)) + return 0 + + result = execute_push(plan, force=args.force) + payload = {"plan": plan, "result": result} + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/skills/issue-drive/SKILL.md b/.claude/skills/issue-drive/SKILL.md index 9a4a401..fa7a2d3 100644 --- a/.claude/skills/issue-drive/SKILL.md +++ b/.claude/skills/issue-drive/SKILL.md @@ -7,7 +7,7 @@ description: 归集证据并把问题拆成 1 到多张 Gitea issue,支持从 > **定位**:不是“查看 issue”,而是把一个真实问题沉淀成可执行的 Gitea issue。适合“线上出 bug 了”“要把一个需求拆成工单”“需要补齐工程治理任务”这类场景。 > -> 如果用户只是想查 issue 列表或详情,用 `issue`。 +> 如果用户只是想查 issue 列表或详情,用 `issue`。如果用户想通过统一入口完成 issue / push / PR 组合操作,用 `gitea`。 当用户调用 `/issue-drive`、`$issue-drive`,或自然语言要求“拆 issue”“提 issue”“把问题沉淀成 Gitea 工单”时,执行以下步骤。 @@ -45,17 +45,15 @@ export GITEA_BASE_URL=https://git.example.com 先读取环境变量: -- `GITEA_ISSUE`:创建 issue 时优先使用 -- `GITEA_TOKEN`:`GITEA_ISSUE` 缺失时回退使用 +- `GITEA_TOKEN`:必需,创建 issue 时使用 - `GITEA_BASE_URL`:可选;仓库简写或 SSH origin 时推荐配置 -如果 `GITEA_ISSUE` 和 `GITEA_TOKEN` 都缺失,输出: +如果缺少 `GITEA_TOKEN`,输出: ```bash -❌ 缺少 GITEA_ISSUE / GITEA_TOKEN +❌ 缺少 GITEA_TOKEN 请先在当前 shell 或 .env 中配置: -export GITEA_ISSUE=your_gitea_write_token export GITEA_TOKEN=your_gitea_token ``` @@ -151,7 +149,7 @@ JSON 结构固定为: - `repo_url` 可省略;脚本会按 `--repo-url` > `spec.repo_url` > `spec.repo` > 当前仓库 `origin` 的顺序解析 - `spec.repo` 允许写 `owner/repo`,但需要 `GITEA_BASE_URL` -- Token 优先读取 `GITEA_ISSUE`,兼容回退 `GITEA_TOKEN` +- Token 固定读取 `GITEA_TOKEN` - `labels` 写标签名,脚本会自动解析成远端 label id - 远端不存在的标签会告警并自动跳过,不中断整个批次 - 先跑 `--dry-run`,确认 payload 和目标仓库无误后再正式创建 diff --git a/.claude/skills/issue-drive/scripts/create_gitea_issues.py b/.claude/skills/issue-drive/scripts/create_gitea_issues.py index 00fbb2f..963bd4e 100644 --- a/.claude/skills/issue-drive/scripts/create_gitea_issues.py +++ b/.claude/skills/issue-drive/scripts/create_gitea_issues.py @@ -43,11 +43,10 @@ def parse_args() -> argparse.Namespace: def load_token(required: bool = True) -> str: - token = os.getenv("GITEA_ISSUE") or os.getenv("GITEA_TOKEN") + token = os.getenv("GITEA_TOKEN") if not token and required: raise SystemExit( - "Missing GITEA_ISSUE (or fallback GITEA_TOKEN). " - "Export the token before creating issues." + "Missing GITEA_TOKEN. Export the token before creating issues." ) return token or "" diff --git a/.claude/skills/issue/SKILL.md b/.claude/skills/issue/SKILL.md index 781052d..e726e3d 100644 --- a/.claude/skills/issue/SKILL.md +++ b/.claude/skills/issue/SKILL.md @@ -5,9 +5,9 @@ description: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条 # Issue - 通用 Gitea Issue 查看 -> **定位**:这是只读 skill。用于查看当前仓库或指定仓库的 issue 列表、单条详情和评论,不负责拆单和创建工单。 +> **定位**:这是只读 skill。用于查看当前仓库或指定仓库的 issue 列表、单条详情和评论,不负责拆单、创建工单、push 或 PR。 > -> 如果用户要做的是“把问题拆成 issue 并实际创建工单”,不要使用本 skill,改用 `issue-drive`。 +> 如果用户要做的是“把问题拆成 issue 并实际创建工单”,改用 `issue-drive`。如果用户想用统一入口处理 Gitea 相关任务,改用 `gitea`。 当用户调用 `/issue`、`$issue`,或自然语言要求“查看当前仓库 issue”“看某个 Gitea 仓库的 issue”“查 issue #17”时,执行以下步骤。 diff --git a/.codex/skills/AGENTS.md.template b/.codex/skills/AGENTS.md.template index 8f3128f..985ca7c 100644 --- a/.codex/skills/AGENTS.md.template +++ b/.codex/skills/AGENTS.md.template @@ -28,6 +28,7 @@ - `doc`: 渐进式文档生成器。首次只写精炼梗概(≤300字),后续通过迭代不断完善。 (file: `./.codex/skills/doc/SKILL.md`) - `update`: 收集用户反馈并更新最近使用的 skill。别名:`up`。 (file: `./.codex/skills/up/SKILL.md`) - `deploy`: Drone CI + 服务器 CD 全流程引导:从基础设施检查到生成配置文件到验证部署,交互式完成。 (file: `./.codex/skills/deploy/SKILL.md`) +- `gitea`: 统一 Gitea 总入口,支持 issue 查询、issue 拆单创建、git push 和 PR 基础操作,优先从当前仓库 origin 自动识别目标仓库。 (file: `./.codex/skills/gitea/SKILL.md`) - `issue`: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条详情,支持自动识别 git origin、用户指定仓库和格式化输出。 (file: `./.codex/skills/issue/SKILL.md`) - `issue-drive`: 归集证据并把问题拆成 1 到多张 Gitea issue,支持从当前仓库 origin 自动识别仓库或用户显式指定。 (file: `./.codex/skills/issue-drive/SKILL.md`) - `changelog`: 一键发版:生成更新日志 → commit → 打 tag,全流程自动化。 (file: `./.codex/skills/changelog/SKILL.md`) diff --git a/.codex/skills/gitea/SKILL.md b/.codex/skills/gitea/SKILL.md new file mode 100644 index 0000000..9cfab45 --- /dev/null +++ b/.codex/skills/gitea/SKILL.md @@ -0,0 +1,144 @@ +--- +name: gitea +description: 统一 Gitea 总入口,支持 issue 查询、issue 拆单创建、git push 和 PR 基础操作,优先从当前仓库 origin 自动识别目标仓库。 +--- + +# Gitea - 统一入口 + +> **定位**:这是 Gitea 总入口。适合“看 issue”“拆 issue”“把提交 push 上去”“给当前分支开 PR”“在 PR 里留言”这类场景。 +> +> `issue` 和 `issue-drive` 继续保留:`issue` 负责只读查看,`issue-drive` 负责拆单和创建工单;`gitea` 负责统一路由和 push / PR 能力。 + +当用户调用 `/gitea`、`$gitea`,或自然语言要求“处理 Gitea / push / PR / issue”时,按以下规则执行。 + +## 1. 先路由意图 + +先从用户输入里判断目标动作: + +- 包含“看 / 查 / list / open / closed / #123 / issue”,且没有“拆 / 建 / 提 / 创建工单”语义:走 `issue` +- 包含“拆 issue / 提 issue / 创建工单 / 沉淀问题”语义:走 `issue-drive` +- 包含“push / 推送 / tag 推上去 / 推当前分支”语义:走 push 流程 +- 包含“PR / pull request / 拉请求 / 合并请求 / 评论 PR”语义:走 PR 流程 + +如果一句话里同时包含多个动作,先按用户描述顺序执行;默认不要自作主张串更多步骤。 + +## 2. 环境变量 + +统一使用: + +- `GITEA_TOKEN`:必需 +- `GITEA_BASE_URL`:可选;当传 `owner/repo` 或当前仓库 `origin` 是 SSH 地址时推荐配置 + +如果缺少 `GITEA_TOKEN`,先提示用户配置后停止,不继续执行 API、push fallback 或 PR 操作。 + +## 3. Issue 路由 + +如果判定为只读 issue: + +- 直接执行 `issue` skill 的流程 +- 保持它的输入规则:当前仓库自动识别、支持 `owner/repo`、支持完整仓库 URL、支持单条 issue 编号 +- 不在 `gitea` 里重复抄写 `issue` 的长说明 + +如果判定为 issue 拆单 / 创建: + +- 直接执行 `issue-drive` 的流程 +- 先整理事实和证据,再创建 issue +- 创建时统一使用 `GITEA_TOKEN` + +## 4. Push 流程 + +Push 一律先跑预检,再决定是否执行: + +```bash +# Codex +python3 .codex/skills/gitea/scripts/push_gitea.py + +# Claude Code +python3 .claude/skills/gitea/scripts/push_gitea.py +``` + +规则: + +- 默认推当前分支到同名远端分支 +- `ahead=0` 时直接告诉用户“没有可推送提交” +- `dirty=true` 时明确提示“只会推送已提交内容” +- 分支 diverged 时停止,不自动 rebase,也不自动 force +- 只有用户明确要求“强推 / force push”时,才带 `--force` + +真正执行时: + +```bash +# Codex +python3 .codex/skills/gitea/scripts/push_gitea.py --execute + +# Claude Code +python3 .claude/skills/gitea/scripts/push_gitea.py --execute +``` + +若用户明确要求强推: + +```bash +python3 .codex/skills/gitea/scripts/push_gitea.py --execute --force +``` + +执行策略: + +- 先尝试 `git push origin branch:branch` +- 若远端鉴权失败,再使用 `GITEA_TOKEN` 调 `/api/v1/user` 获取登录名,构造一次性 HTTPS token URL 进行 fallback +- 不改写本地 `origin` + +## 5. PR 流程 + +PR 只做三件事:`list`、`create`、`comment`。 + +### 5.1 看 PR + +```bash +# Codex +python3 .codex/skills/gitea/scripts/pr_gitea.py list --state=open --limit=20 +``` + +### 5.2 开 PR + +```bash +# Codex +python3 .codex/skills/gitea/scripts/pr_gitea.py create --base main --title "标题" --body "正文" +``` + +规则: + +- `head` 默认当前分支 +- `base` 默认远端默认分支;识别不到时再显式传 `--base` +- 如果当前分支还没推送到远端,脚本会先走 push 流程,再创建 PR +- v1 只支持同仓库 PR,不处理 fork 间 PR + +### 5.3 在 PR 里留言 + +```bash +# Codex +python3 .codex/skills/gitea/scripts/pr_gitea.py comment 12 --body "已完成回归" +``` + +规则: + +- PR 评论走 issue comment API,因为 Gitea PR 底层复用 issue 线程 + +## 6. 输出要求 + +- issue 查询:沿用 `issue` skill 的固定 Markdown 输出 +- issue 创建:沿用 `issue-drive` 的创建结果输出 +- push:输出预检 JSON 或执行结果 JSON,至少包含 `status`、`branch`、`ahead/behind`、是否执行、执行方式 +- PR:输出 PR 列表或单条创建 / 评论结果 JSON,至少包含编号、标题、URL、状态 + +## 7. 用法示例 + +```bash +/gitea 看当前仓库 open issues +/gitea 查 issue 17 +/gitea 把这个 bug 拆成两张 issue +/gitea push +/gitea 推送当前分支到远端 +/gitea 给当前分支开 PR 到 main +/gitea 看当前仓库 open PR +/gitea 在 PR 12 留言:已完成回归 +``` diff --git a/.codex/skills/gitea/scripts/common.py b/.codex/skills/gitea/scripts/common.py new file mode 100644 index 0000000..1ef2240 --- /dev/null +++ b/.codex/skills/gitea/scripts/common.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""Shared helpers for Gitea skill scripts.""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass + +SSH_RE = re.compile(r"^git@(?P[^:]+):(?P.+?)(?:\.git)?/?$") +REPO_PATH_RE = re.compile(r"^(?P[^/]+)/(?P[^/]+)$") + + +class GitCommandError(RuntimeError): + """Raised when a git command fails.""" + + +@dataclass +class RepoContext: + origin: str + owner: str + repo: str + repo_url: str + remote_name: str = "origin" + remote_url: str | None = None + + +def normalize_base_url(base_url: str) -> str: + return base_url.rstrip("/") + + +def ensure_git_repo() -> None: + result = subprocess.run( + ["git", "rev-parse", "--is-inside-work-tree"], + capture_output=True, + text=True, + ) + if result.returncode != 0 or result.stdout.strip() != "true": + raise SystemExit("Current directory is not a git repository.") + + +def run_git(args: list[str], cwd: str | None = None, check: bool = True) -> str: + result = subprocess.run( + ["git", *args], + capture_output=True, + text=True, + cwd=cwd, + ) + if check and result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or "unknown git error" + raise GitCommandError(f"git {' '.join(args)} failed: {detail}") + return result.stdout.strip() + + +def parse_http_repo_url(repo_url: str) -> tuple[str, str, str, str]: + parsed = urllib.parse.urlsplit(repo_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise SystemExit("Invalid repo URL. Expected format: https://host/owner/repo") + + path = parsed.path.rstrip("/") + if path.endswith(".git"): + path = path[:-4] + + parts = [part for part in path.split("/") if part] + if len(parts) < 2: + raise SystemExit("Invalid repo URL. Expected format: https://host/owner/repo") + + owner, repo = parts[-2], parts[-1] + prefix = "/".join(parts[:-2]) + origin = f"{parsed.scheme}://{parsed.netloc}" + if prefix: + origin = f"{origin}/{prefix}" + normalized_repo_url = f"{origin}/{owner}/{repo}" + return origin, owner, repo, normalized_repo_url + + +def parse_repo_target( + repo_target: str, + base_url: str | None = None, +) -> tuple[str, str, str, str]: + value = repo_target.strip() + if not value: + raise SystemExit("Repo target cannot be empty.") + + if value.startswith("http://") or value.startswith("https://"): + return parse_http_repo_url(value) + + if value.startswith("ssh://"): + parsed = urllib.parse.urlsplit(value) + path = parsed.path.rstrip("/") + if path.endswith(".git"): + path = path[:-4] + parts = [part for part in path.split("/") if part] + if len(parts) < 2: + raise SystemExit( + "Invalid SSH repo URL. Expected ssh://git@host/owner/repo.git" + ) + owner, repo = parts[-2], parts[-1] + prefix = "/".join(parts[:-2]) + host = parsed.hostname + if not host: + raise SystemExit("Invalid SSH repo URL. Missing host.") + origin = ( + normalize_base_url(base_url) + if base_url + else f"https://{host}{f'/{prefix}' if prefix else ''}" + ) + return origin, owner, repo, f"{origin}/{owner}/{repo}" + + ssh_match = SSH_RE.match(value) + if ssh_match: + path = ssh_match.group("path").rstrip("/") + if path.endswith(".git"): + path = path[:-4] + parts = [part for part in path.split("/") if part] + if len(parts) < 2: + raise SystemExit( + "Invalid SSH repo target. Expected git@host:owner/repo.git" + ) + owner, repo = parts[-2], parts[-1] + prefix = "/".join(parts[:-2]) + origin = ( + normalize_base_url(base_url) + if base_url + else f"https://{ssh_match.group('host')}{f'/{prefix}' if prefix else ''}" + ) + return origin, owner, repo, f"{origin}/{owner}/{repo}" + + path_match = REPO_PATH_RE.match(value) + if path_match: + if not base_url: + raise SystemExit( + "Repo shorthand owner/repo requires GITEA_BASE_URL or a full repo URL." + ) + origin = normalize_base_url(base_url) + owner = path_match.group("owner") + repo = path_match.group("repo") + return origin, owner, repo, f"{origin}/{owner}/{repo}" + + raise SystemExit( + "Invalid repo target. Use https://host/owner/repo, git@host:owner/repo.git, " + "ssh://git@host/owner/repo.git, or owner/repo with GITEA_BASE_URL." + ) + + +def get_remote_url(remote: str = "origin") -> str: + ensure_git_repo() + try: + return run_git(["remote", "get-url", remote]) + except GitCommandError as exc: + raise SystemExit(f"Failed to read git remote '{remote}': {exc}") from exc + + +def resolve_repo( + repo_url: str | None = None, + repo: str | None = None, + remote: str = "origin", +) -> RepoContext: + base_url = os.getenv("GITEA_BASE_URL") + remote_url = None + target = repo_url or repo + if not target: + remote_url = get_remote_url(remote) + target = remote_url + origin, owner, repo_name, normalized_repo_url = parse_repo_target( + target, + base_url=base_url, + ) + return RepoContext( + origin=origin, + owner=owner, + repo=repo_name, + repo_url=normalized_repo_url, + remote_name=remote, + remote_url=remote_url, + ) + + +def api_base(context: RepoContext) -> str: + return f"{context.origin}/api/v1/repos/{context.owner}/{context.repo}" + + +def load_token(required: bool = True) -> str: + token = os.getenv("GITEA_TOKEN", "").strip() + if not token and required: + raise SystemExit("Missing GITEA_TOKEN. Export it before using Gitea workflows.") + return token + + +def request_json( + url: str, + token: str, + method: str = "GET", + payload: dict | None = None, +) -> dict | list: + data = None + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = f"token {token}" + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + + request = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(request) as response: + return json.load(response) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise SystemExit( + f"Gitea API request failed: {exc.code} {exc.reason} | {body}" + ) from exc + except urllib.error.URLError as exc: + raise SystemExit(f"Failed to reach Gitea API: {exc.reason}") from exc + + +def get_current_user_login(origin: str, token: str) -> str: + data = request_json(f"{origin}/api/v1/user", token) + if not isinstance(data, dict): + raise SystemExit("Unexpected user API response.") + login = str(data.get("login") or "").strip() + if not login: + raise SystemExit("Failed to resolve current Gitea user login.") + return login + + +def current_branch() -> str: + ensure_git_repo() + try: + branch = run_git(["symbolic-ref", "--quiet", "--short", "HEAD"]) + except GitCommandError as exc: + raise SystemExit( + "Detached HEAD. Checkout a branch before running push or PR actions." + ) from exc + if not branch: + raise SystemExit("Failed to determine current git branch.") + return branch + + +def get_upstream(branch: str) -> str | None: + result = subprocess.run( + [ + "git", + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + f"{branch}@{{upstream}}", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + upstream = result.stdout.strip() + return upstream or None + + +def remote_branch_sha(remote: str, branch: str) -> str | None: + try: + output = run_git(["ls-remote", "--heads", remote, branch]) + except GitCommandError as exc: + raise SystemExit(f"Failed to query remote branch '{remote}/{branch}': {exc}") from exc + if not output: + return None + return output.split()[0] + + +def ahead_behind(compare_ref: str) -> tuple[int, int]: + output = run_git(["rev-list", "--left-right", "--count", f"{compare_ref}...HEAD"]) + parts = output.split() + if len(parts) != 2: + raise SystemExit(f"Unexpected rev-list output: {output}") + behind, ahead = (int(part) for part in parts) + return behind, ahead + + +def worktree_changes() -> list[str]: + output = run_git(["status", "--short"]) + return [line for line in output.splitlines() if line.strip()] + + +def remote_default_branch(remote: str = "origin") -> str: + try: + output = run_git(["symbolic-ref", "--quiet", "--short", f"refs/remotes/{remote}/HEAD"]) + if output.startswith(f"{remote}/"): + return output.split("/", 1)[1] + except GitCommandError: + pass + + for candidate in ("main", "master"): + if remote_branch_sha(remote, candidate): + return candidate + raise SystemExit( + f"Failed to infer default branch for remote '{remote}'. Pass --base explicitly." + ) + + +def build_authenticated_push_url(repo_url: str, username: str, token: str) -> str: + parsed = urllib.parse.urlsplit(repo_url) + path = parsed.path.rstrip("/") + if not path.endswith(".git"): + path = f"{path}.git" + netloc = ( + f"{urllib.parse.quote(username, safe='')}:" + f"{urllib.parse.quote(token, safe='')}@{parsed.netloc}" + ) + return urllib.parse.urlunsplit((parsed.scheme or "https", netloc, path, "", "")) + + +def mask_url(url: str) -> str: + parsed = urllib.parse.urlsplit(url) + if "@" not in parsed.netloc: + return url + _, host = parsed.netloc.rsplit("@", 1) + return urllib.parse.urlunsplit((parsed.scheme, f"***@{host}", parsed.path, "", "")) + + +def is_auth_error(stderr: str) -> bool: + message = stderr.lower() + indicators = ( + "authentication failed", + "permission denied", + "could not read username", + "could not read password", + "http basic: access denied", + "access denied", + "unauthorized", + ) + return any(indicator in message for indicator in indicators) diff --git a/.codex/skills/gitea/scripts/pr_gitea.py b/.codex/skills/gitea/scripts/pr_gitea.py new file mode 100644 index 0000000..a3f2e96 --- /dev/null +++ b/.codex/skills/gitea/scripts/pr_gitea.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""List, create, and comment on Gitea pull requests.""" + +from __future__ import annotations + +import argparse +import json +import sys + +from common import ( + api_base, + current_branch, + load_token, + remote_default_branch, + request_json, + resolve_repo, +) +from push_gitea import compute_push_plan, execute_push + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="List, create, and comment on Gitea pull requests." + ) + parser.add_argument("--repo-url", help="Explicit target repo URL.") + parser.add_argument("--repo", help="Shorthand owner/repo. Requires GITEA_BASE_URL.") + parser.add_argument("--remote", default="origin", help="Git remote name. Default: origin") + + subparsers = parser.add_subparsers(dest="command", required=True) + + list_parser = subparsers.add_parser("list", help="List pull requests.") + list_parser.add_argument( + "--state", + default="open", + choices=("open", "closed", "all"), + help="Pull request state filter.", + ) + list_parser.add_argument( + "--limit", + default=20, + type=int, + help="Maximum number of pull requests to return.", + ) + + create_parser = subparsers.add_parser("create", help="Create a pull request.") + create_parser.add_argument("--base", help="Base branch. Default: remote default branch.") + create_parser.add_argument("--head", help="Head branch. Default: current branch.") + create_parser.add_argument("--title", required=True, help="Pull request title.") + create_parser.add_argument("--body", default="", help="Pull request body.") + + comment_parser = subparsers.add_parser("comment", help="Comment on a pull request.") + comment_parser.add_argument("pr", type=int, help="Pull request number.") + comment_parser.add_argument("--body", required=True, help="Comment body.") + + return parser.parse_args() + + +def summarize_pull_request(item: dict) -> dict: + user = item.get("user") or {} + head = item.get("head") or {} + base = item.get("base") or {} + return { + "number": item.get("number"), + "title": item.get("title"), + "state": item.get("state"), + "html_url": item.get("html_url"), + "author": user.get("full_name") or user.get("login"), + "head": head.get("ref"), + "base": base.get("ref"), + "created_at": item.get("created_at"), + "updated_at": item.get("updated_at"), + } + + +def list_pull_requests(args: argparse.Namespace) -> dict: + context = resolve_repo(repo_url=args.repo_url, repo=args.repo, remote=args.remote) + token = load_token(required=True) + url = f"{api_base(context)}/pulls?state={args.state}&page=1&limit={args.limit}" + data = request_json(url, token) + if not isinstance(data, list): + raise SystemExit("Unexpected pull request list response.") + return { + "action": "list", + "repo_url": context.repo_url, + "state": args.state, + "limit": args.limit, + "count": len(data), + "pull_requests": [summarize_pull_request(item) for item in data], + } + + +def create_pull_request(args: argparse.Namespace) -> dict: + context = resolve_repo(repo_url=args.repo_url, repo=args.repo, remote=args.remote) + token = load_token(required=True) + head = args.head or current_branch() + base = args.base or remote_default_branch(args.remote) + + push_plan = compute_push_plan( + repo_url=args.repo_url, + repo=args.repo, + remote=args.remote, + branch=head, + force=False, + ) + push_result = None + if push_plan["needs_push"]: + push_result = execute_push(push_plan, force=False) + elif push_plan["status"] == "blocked": + raise SystemExit( + "Cannot create PR because the current branch has diverged from the remote branch." + ) + + payload = {"base": base, "head": head, "title": args.title, "body": args.body} + created = request_json(f"{api_base(context)}/pulls", token, method="POST", payload=payload) + if not isinstance(created, dict): + raise SystemExit("Unexpected pull request creation response.") + return { + "action": "create", + "repo_url": context.repo_url, + "base": base, + "head": head, + "push": push_result, + "pull_request": summarize_pull_request(created), + } + + +def comment_pull_request(args: argparse.Namespace) -> dict: + context = resolve_repo(repo_url=args.repo_url, repo=args.repo, remote=args.remote) + token = load_token(required=True) + payload = {"body": args.body} + created = request_json( + f"{api_base(context)}/issues/{args.pr}/comments", + token, + method="POST", + payload=payload, + ) + if not isinstance(created, dict): + raise SystemExit("Unexpected PR comment response.") + user = created.get("user") or {} + return { + "action": "comment", + "repo_url": context.repo_url, + "pull_request": args.pr, + "comment": { + "id": created.get("id"), + "html_url": created.get("html_url"), + "author": user.get("full_name") or user.get("login"), + "created_at": created.get("created_at"), + }, + } + + +def main() -> int: + args = parse_args() + if args.command == "list": + payload = list_pull_requests(args) + elif args.command == "create": + payload = create_pull_request(args) + else: + payload = comment_pull_request(args) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.codex/skills/gitea/scripts/push_gitea.py b/.codex/skills/gitea/scripts/push_gitea.py new file mode 100644 index 0000000..6b4b9cf --- /dev/null +++ b/.codex/skills/gitea/scripts/push_gitea.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Preflight and execute git push with optional Gitea token fallback.""" + +from __future__ import annotations + +import argparse +import json +import shlex +import subprocess +import sys + +from common import ( + build_authenticated_push_url, + current_branch, + ensure_git_repo, + get_current_user_login, + get_upstream, + is_auth_error, + load_token, + mask_url, + remote_branch_sha, + resolve_repo, + worktree_changes, + ahead_behind, +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Preflight and optionally push the current branch to Gitea." + ) + parser.add_argument("--repo-url", help="Explicit target repo URL.") + parser.add_argument("--repo", help="Shorthand owner/repo. Requires GITEA_BASE_URL.") + parser.add_argument("--remote", default="origin", help="Git remote name. Default: origin") + parser.add_argument("--branch", help="Branch to push. Default: current branch") + parser.add_argument( + "--execute", + action="store_true", + help="Run git push after preflight instead of only printing the plan.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Allow force push. Uses --force-with-lease under the hood.", + ) + return parser.parse_args() + + +def build_push_command(target: str, branch: str, force: bool) -> list[str]: + command = ["git", "push"] + if force: + command.append("--force-with-lease") + command.extend([target, f"{branch}:{branch}"]) + return command + + +def command_text(command: list[str]) -> str: + return " ".join(shlex.quote(part) for part in command) + + +def compute_push_plan( + repo_url: str | None = None, + repo: str | None = None, + remote: str = "origin", + branch: str | None = None, + force: bool = False, +) -> dict: + ensure_git_repo() + context = resolve_repo(repo_url=repo_url, repo=repo, remote=remote) + branch_name = branch or current_branch() + dirty_lines = worktree_changes() + upstream = get_upstream(branch_name) + compare_ref = upstream + remote_exists = True + ahead = 0 + behind = 0 + + if upstream: + behind, ahead = ahead_behind(upstream) + else: + remote_sha = remote_branch_sha(remote, branch_name) + if remote_sha: + compare_ref = f"{remote}/{branch_name}" + behind, ahead = ahead_behind(remote_sha) + else: + remote_exists = False + compare_ref = None + ahead = None + behind = 0 + + diverged = bool(remote_exists and behind > 0 and (ahead or 0) > 0) + behind_only = bool(remote_exists and behind > 0 and (ahead or 0) == 0) + needs_push = (not remote_exists) or ((ahead or 0) > 0) + + status = "ready" + if diverged and not force: + status = "blocked" + elif not needs_push: + status = "noop" + + messages: list[str] = [] + if dirty_lines: + messages.append("Working tree is dirty; push will include only committed changes.") + if not remote_exists: + messages.append("Remote branch does not exist yet; push will create it.") + if behind_only: + messages.append("Local branch is behind the remote branch; there are no local commits to push.") + if diverged and not force: + messages.append("Branch has diverged from remote; rerun with explicit force only if that is intended.") + + push_command = build_push_command(remote, branch_name, force) + fallback_command = None + token_available = bool(load_token(required=False)) + if token_available: + try: + token = load_token(required=False) + login = get_current_user_login(context.origin, token) + auth_url = build_authenticated_push_url(context.repo_url, login, token) + fallback_command = build_push_command(auth_url, branch_name, force) + except SystemExit as exc: + messages.append(f"Failed to prepare HTTPS token fallback: {exc}") + + return { + "repo_url": context.repo_url, + "origin": context.origin, + "owner": context.owner, + "repo": context.repo, + "remote": remote, + "branch": branch_name, + "upstream": upstream, + "compare_ref": compare_ref, + "remote_branch_exists": remote_exists, + "dirty": bool(dirty_lines), + "dirty_paths": dirty_lines, + "ahead": ahead, + "behind": behind, + "diverged": diverged, + "behind_only": behind_only, + "needs_push": needs_push, + "status": status, + "messages": messages, + "push_command": command_text(push_command), + "fallback_push_command": command_text( + [ + *(fallback_command[:-2] if fallback_command else []), + mask_url(fallback_command[-2]) if fallback_command else "", + *(fallback_command[-1:] if fallback_command else []), + ] + ) + if fallback_command + else None, + } + + +def run_command(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, capture_output=True, text=True) + + +def execute_push(plan: dict, force: bool = False) -> dict: + if plan["status"] == "noop": + return { + "status": "noop", + "executed": False, + "reason": "nothing_to_push", + "messages": plan["messages"], + } + if plan["diverged"] and not force: + raise SystemExit( + "Branch has diverged from remote. Refusing to push without explicit --force." + ) + + primary_command = build_push_command(plan["remote"], plan["branch"], force) + primary_result = run_command(primary_command) + if primary_result.returncode == 0: + return { + "status": "pushed", + "executed": True, + "method": "remote", + "command": command_text(primary_command), + "stdout": primary_result.stdout.strip(), + "stderr": primary_result.stderr.strip(), + } + + primary_stderr = primary_result.stderr.strip() or primary_result.stdout.strip() + if not is_auth_error(primary_stderr): + raise SystemExit(f"git push failed: {primary_stderr}") + + token = load_token(required=True) + login = get_current_user_login(plan["origin"], token) + auth_url = build_authenticated_push_url(plan["repo_url"], login, token) + fallback_command = build_push_command(auth_url, plan["branch"], force) + fallback_result = run_command(fallback_command) + if fallback_result.returncode == 0: + return { + "status": "pushed", + "executed": True, + "method": "https-token", + "command": command_text( + [ + *(fallback_command[:-2]), + mask_url(fallback_command[-2]), + fallback_command[-1], + ] + ), + "stdout": fallback_result.stdout.strip(), + "stderr": fallback_result.stderr.strip(), + } + + fallback_stderr = fallback_result.stderr.strip() or fallback_result.stdout.strip() + raise SystemExit( + "git push failed with remote auth and HTTPS token fallback: " + f"{fallback_stderr}" + ) + + +def main() -> int: + args = parse_args() + plan = compute_push_plan( + repo_url=args.repo_url, + repo=args.repo, + remote=args.remote, + branch=args.branch, + force=args.force, + ) + if not args.execute: + print(json.dumps(plan, ensure_ascii=False, indent=2)) + return 0 + + result = execute_push(plan, force=args.force) + payload = {"plan": plan, "result": result} + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.codex/skills/issue-drive/SKILL.md b/.codex/skills/issue-drive/SKILL.md index 9a4a401..fa7a2d3 100644 --- a/.codex/skills/issue-drive/SKILL.md +++ b/.codex/skills/issue-drive/SKILL.md @@ -7,7 +7,7 @@ description: 归集证据并把问题拆成 1 到多张 Gitea issue,支持从 > **定位**:不是“查看 issue”,而是把一个真实问题沉淀成可执行的 Gitea issue。适合“线上出 bug 了”“要把一个需求拆成工单”“需要补齐工程治理任务”这类场景。 > -> 如果用户只是想查 issue 列表或详情,用 `issue`。 +> 如果用户只是想查 issue 列表或详情,用 `issue`。如果用户想通过统一入口完成 issue / push / PR 组合操作,用 `gitea`。 当用户调用 `/issue-drive`、`$issue-drive`,或自然语言要求“拆 issue”“提 issue”“把问题沉淀成 Gitea 工单”时,执行以下步骤。 @@ -45,17 +45,15 @@ export GITEA_BASE_URL=https://git.example.com 先读取环境变量: -- `GITEA_ISSUE`:创建 issue 时优先使用 -- `GITEA_TOKEN`:`GITEA_ISSUE` 缺失时回退使用 +- `GITEA_TOKEN`:必需,创建 issue 时使用 - `GITEA_BASE_URL`:可选;仓库简写或 SSH origin 时推荐配置 -如果 `GITEA_ISSUE` 和 `GITEA_TOKEN` 都缺失,输出: +如果缺少 `GITEA_TOKEN`,输出: ```bash -❌ 缺少 GITEA_ISSUE / GITEA_TOKEN +❌ 缺少 GITEA_TOKEN 请先在当前 shell 或 .env 中配置: -export GITEA_ISSUE=your_gitea_write_token export GITEA_TOKEN=your_gitea_token ``` @@ -151,7 +149,7 @@ JSON 结构固定为: - `repo_url` 可省略;脚本会按 `--repo-url` > `spec.repo_url` > `spec.repo` > 当前仓库 `origin` 的顺序解析 - `spec.repo` 允许写 `owner/repo`,但需要 `GITEA_BASE_URL` -- Token 优先读取 `GITEA_ISSUE`,兼容回退 `GITEA_TOKEN` +- Token 固定读取 `GITEA_TOKEN` - `labels` 写标签名,脚本会自动解析成远端 label id - 远端不存在的标签会告警并自动跳过,不中断整个批次 - 先跑 `--dry-run`,确认 payload 和目标仓库无误后再正式创建 diff --git a/.codex/skills/issue-drive/scripts/create_gitea_issues.py b/.codex/skills/issue-drive/scripts/create_gitea_issues.py index 00fbb2f..963bd4e 100644 --- a/.codex/skills/issue-drive/scripts/create_gitea_issues.py +++ b/.codex/skills/issue-drive/scripts/create_gitea_issues.py @@ -43,11 +43,10 @@ def parse_args() -> argparse.Namespace: def load_token(required: bool = True) -> str: - token = os.getenv("GITEA_ISSUE") or os.getenv("GITEA_TOKEN") + token = os.getenv("GITEA_TOKEN") if not token and required: raise SystemExit( - "Missing GITEA_ISSUE (or fallback GITEA_TOKEN). " - "Export the token before creating issues." + "Missing GITEA_TOKEN. Export the token before creating issues." ) return token or "" diff --git a/.codex/skills/issue/SKILL.md b/.codex/skills/issue/SKILL.md index 781052d..e726e3d 100644 --- a/.codex/skills/issue/SKILL.md +++ b/.codex/skills/issue/SKILL.md @@ -5,9 +5,9 @@ description: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条 # Issue - 通用 Gitea Issue 查看 -> **定位**:这是只读 skill。用于查看当前仓库或指定仓库的 issue 列表、单条详情和评论,不负责拆单和创建工单。 +> **定位**:这是只读 skill。用于查看当前仓库或指定仓库的 issue 列表、单条详情和评论,不负责拆单、创建工单、push 或 PR。 > -> 如果用户要做的是“把问题拆成 issue 并实际创建工单”,不要使用本 skill,改用 `issue-drive`。 +> 如果用户要做的是“把问题拆成 issue 并实际创建工单”,改用 `issue-drive`。如果用户想用统一入口处理 Gitea 相关任务,改用 `gitea`。 当用户调用 `/issue`、`$issue`,或自然语言要求“查看当前仓库 issue”“看某个 Gitea 仓库的 issue”“查 issue #17”时,执行以下步骤。 diff --git a/.gitignore b/.gitignore index 4132ecd..4f3c2f6 100644 --- a/.gitignore +++ b/.gitignore @@ -15,10 +15,20 @@ write-skills/ !.agents/skills/issue-drive/SKILL.md !.agents/skills/issue-drive/scripts/ !.agents/skills/issue-drive/scripts/create_gitea_issues.py +!.agents/skills/gitea/ +!.agents/skills/gitea/SKILL.md +!.agents/skills/gitea/scripts/ +!.agents/skills/gitea/scripts/common.py +!.agents/skills/gitea/scripts/push_gitea.py +!.agents/skills/gitea/scripts/pr_gitea.py # macOS .DS_Store +# Python +__pycache__/ +*.pyc + # IDE .idea/ .vscode/ diff --git a/AGENTS.md b/AGENTS.md index 6804f11..c972f1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ - `doc`: 渐进式文档生成器。首次只写精炼梗概(≤300字),后续通过迭代不断完善。 (file: `./.codex/skills/doc/SKILL.md`) - `update`: 收集用户反馈并更新最近使用的 skill。别名:`up`。 (file: `./.codex/skills/up/SKILL.md`) - `deploy`: Drone CI + 服务器 CD 全流程引导:从基础设施检查到生成配置文件到验证部署,交互式完成。 (file: `./.codex/skills/deploy/SKILL.md`) +- `gitea`: 统一 Gitea 总入口,支持 issue 查询、issue 拆单创建、git push 和 PR 基础操作,优先从当前仓库 origin 自动识别目标仓库。 (file: `./.codex/skills/gitea/SKILL.md`) - `issue`: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条详情,支持自动识别 git origin、用户指定仓库和格式化输出。 (file: `./.codex/skills/issue/SKILL.md`) - `issue-drive`: 归集证据并把问题拆成 1 到多张 Gitea issue,支持从当前仓库 origin 自动识别仓库或用户显式指定。 (file: `./.codex/skills/issue-drive/SKILL.md`) - `changelog`: 一键发版:生成更新日志 → commit → 打 tag,全流程自动化。 (file: `./.codex/skills/changelog/SKILL.md`) diff --git a/AGENTS.md.template b/AGENTS.md.template index 8f3128f..985ca7c 100644 --- a/AGENTS.md.template +++ b/AGENTS.md.template @@ -28,6 +28,7 @@ - `doc`: 渐进式文档生成器。首次只写精炼梗概(≤300字),后续通过迭代不断完善。 (file: `./.codex/skills/doc/SKILL.md`) - `update`: 收集用户反馈并更新最近使用的 skill。别名:`up`。 (file: `./.codex/skills/up/SKILL.md`) - `deploy`: Drone CI + 服务器 CD 全流程引导:从基础设施检查到生成配置文件到验证部署,交互式完成。 (file: `./.codex/skills/deploy/SKILL.md`) +- `gitea`: 统一 Gitea 总入口,支持 issue 查询、issue 拆单创建、git push 和 PR 基础操作,优先从当前仓库 origin 自动识别目标仓库。 (file: `./.codex/skills/gitea/SKILL.md`) - `issue`: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条详情,支持自动识别 git origin、用户指定仓库和格式化输出。 (file: `./.codex/skills/issue/SKILL.md`) - `issue-drive`: 归集证据并把问题拆成 1 到多张 Gitea issue,支持从当前仓库 origin 自动识别仓库或用户显式指定。 (file: `./.codex/skills/issue-drive/SKILL.md`) - `changelog`: 一键发版:生成更新日志 → commit → 打 tag,全流程自动化。 (file: `./.codex/skills/changelog/SKILL.md`) diff --git a/README.md b/README.md index 30a722b..d3216c3 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,9 @@ RequirementsDoc ──▶ PRD ──▶ FeatureSummary ──▶ DevelopmentPlan | | `doc` | `/doc` | `/doc` | 渐进式文档生成器,先写梗概再迭代完善 | | | `update` | `/up` | `/up` | Skill 升级优化 | | | `deploy` | `/deploy` | `/deploy` | Drone CI/CD 全流程部署引导 | -| | `issue` | `/issue` | `/issue` | 通用 Gitea issue 查询(支持当前仓库自动识别) | -| | `issue-drive` | `/issue-drive` | `/issue-drive` | 通用 Gitea issue 拆单与批量创建 | +| | `gitea` | `/gitea` | `/gitea` | 统一 Gitea 入口(issue / push / PR) | +| | `issue` | `/issue` | `/issue` | Gitea issue 只读专用入口(支持当前仓库自动识别) | +| | `issue-drive` | `/issue-drive` | `/issue-drive` | Gitea issue 拆单与批量创建专用入口 | | | `changelog` | `/changelog` | `/changelog` | 一键发版(日志 + commit + tag) | > Codex 兼容历史 `$skill` 写法,但本文档统一以 `/skill` 作为主入口。 @@ -147,22 +148,58 @@ Codex: 请用 wf skill 根据 PRD 生成 FeatureSummary 请用 go skill 按 doc/tasks.md 执行未完成任务 请用 doc skill 为认证模块写一份 300 字以内的使用说明 -请用 issue skill 查看当前仓库的 open issues +请用 gitea skill 看当前仓库 open issues +请用 gitea skill 给当前分支开 PR 到 main +请用 gitea skill push 当前分支到远端 请用 issue-drive skill 把当前 bug 拆成两张 Gitea issue ``` -### 查询 Gitea Issues +### 统一 Gitea 入口 先配置环境变量: ```bash export GITEA_TOKEN=your_gitea_token -export GITEA_ISSUE=your_gitea_write_token # 可选,写 issue 时优先使用 export GITEA_BASE_URL=https://git.example.com # 可选;owner/repo 简写或 SSH origin 时推荐配置 ``` Claude Code: +```bash +/gitea 看当前仓库 open issues +/gitea 查 issue 7 +/gitea 把这个 bug 拆成两张 issue +/gitea push +/gitea 给当前分支开 PR 到 main +/gitea 看当前仓库 open PR +/gitea 在 PR 12 留言:已完成回归 +``` + +Codex: + +```text +/gitea 看当前仓库 open issues +/gitea 查 issue 7 +/gitea 把这个 bug 拆成两张 issue +/gitea push +/gitea 给当前分支开 PR 到 main +/gitea 看当前仓库 open PR +/gitea 在 PR 12 留言:已完成回归 +``` + +说明: + +- `gitea` 是统一入口,会按自然语言自动路由到 issue、issue-drive、push 或 PR +- push 默认先预检,再执行;只有明确要求 force push 时才会强推 +- PR v1 只支持同仓库 list / create / comment +- 直接 `/gitea 看 issue 7` 或 `/gitea push` 时,skill 会优先从当前项目的 `git origin` 自动识别仓库 +- 传 `owner/repo` 时,需要 `GITEA_BASE_URL` +- 当前仓库 `origin` 是 SSH 地址时,建议配置 `GITEA_BASE_URL`,避免 Web/API 域名与 SSH 域名不一致 + +### 专用入口:查询 Gitea Issues + +Claude Code: + ```bash /issue /issue 7 @@ -183,13 +220,7 @@ Codex: /issue https://git.example.com/owner/repo 7 ``` -说明: - -- 直接 `/issue` 或 `/issue 7` 时,skill 会优先从当前项目的 `git origin` 自动识别仓库 -- 传 `owner/repo` 时,需要 `GITEA_BASE_URL` -- 当前仓库 `origin` 是 SSH 地址时,建议配置 `GITEA_BASE_URL`,避免 Web/API 域名与 SSH 域名不一致 - -### 创建 Gitea Issues +### 专用入口:创建 Gitea Issues Claude Code: @@ -250,6 +281,7 @@ your-project/ │ ├── rp/ │ ├── ... │ ├── iter/ +│ ├── gitea/ │ └── issue-drive/ ├── .claude/ │ └── skills/ # ← Claude Code Skills 安装位置 @@ -257,6 +289,7 @@ your-project/ │ ├── rp/ │ ├── ... │ ├── iter/ +│ ├── gitea/ │ └── issue-drive/ ├── doc/ # ← 文档输出位置 │ ├── RequirementsDoc.md