Add unified Gitea skill workflows

This commit is contained in:
zfc 2026-03-12 17:23:08 +08:00
parent 22765a2917
commit c27b85193f
26 changed files with 2725 additions and 48 deletions

View File

@ -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 留言:已完成回归
```

View File

@ -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<host>[^:]+):(?P<path>.+?)(?:\.git)?/?$")
REPO_PATH_RE = re.compile(r"^(?P<owner>[^/]+)/(?P<repo>[^/]+)$")
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)

View File

@ -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())

View File

@ -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())

View File

@ -7,7 +7,7 @@ description: 归集证据并把问题拆成 1 到多张 Gitea issue支持从
> **定位**:不是“查看 issue”而是把一个真实问题沉淀成可执行的 Gitea issue。适合“线上出 bug 了”“要把一个需求拆成工单”“需要补齐工程治理任务”这类场景。 > **定位**:不是“查看 issue”而是把一个真实问题沉淀成可执行的 Gitea issue。适合“线上出 bug 了”“要把一个需求拆成工单”“需要补齐工程治理任务”这类场景。
> >
> 如果用户只是想查 issue 列表或详情,用 `issue` > 如果用户只是想查 issue 列表或详情,用 `issue`如果用户想通过统一入口完成 issue / push / PR 组合操作,用 `gitea`
当用户调用 `/issue-drive``$issue-drive`,或自然语言要求“拆 issue”“提 issue”“把问题沉淀成 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`:必需,创建 issue 时使用
- `GITEA_TOKEN``GITEA_ISSUE` 缺失时回退使用
- `GITEA_BASE_URL`:可选;仓库简写或 SSH origin 时推荐配置 - `GITEA_BASE_URL`:可选;仓库简写或 SSH origin 时推荐配置
如果 `GITEA_ISSUE``GITEA_TOKEN` 都缺失,输出: 如果缺少 `GITEA_TOKEN`,输出:
```bash ```bash
❌ 缺少 GITEA_ISSUE / GITEA_TOKEN ❌ 缺少 GITEA_TOKEN
请先在当前 shell 或 .env 中配置: 请先在当前 shell 或 .env 中配置:
export GITEA_ISSUE=your_gitea_write_token
export GITEA_TOKEN=your_gitea_token export GITEA_TOKEN=your_gitea_token
``` ```
@ -151,7 +149,7 @@ JSON 结构固定为:
- `repo_url` 可省略;脚本会按 `--repo-url` > `spec.repo_url` > `spec.repo` > 当前仓库 `origin` 的顺序解析 - `repo_url` 可省略;脚本会按 `--repo-url` > `spec.repo_url` > `spec.repo` > 当前仓库 `origin` 的顺序解析
- `spec.repo` 允许写 `owner/repo`,但需要 `GITEA_BASE_URL` - `spec.repo` 允许写 `owner/repo`,但需要 `GITEA_BASE_URL`
- Token 优先读取 `GITEA_ISSUE`,兼容回退 `GITEA_TOKEN` - Token 固定读取 `GITEA_TOKEN`
- `labels` 写标签名,脚本会自动解析成远端 label id - `labels` 写标签名,脚本会自动解析成远端 label id
- 远端不存在的标签会告警并自动跳过,不中断整个批次 - 远端不存在的标签会告警并自动跳过,不中断整个批次
- 先跑 `--dry-run`,确认 payload 和目标仓库无误后再正式创建 - 先跑 `--dry-run`,确认 payload 和目标仓库无误后再正式创建

View File

@ -43,11 +43,10 @@ def parse_args() -> argparse.Namespace:
def load_token(required: bool = True) -> str: 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: if not token and required:
raise SystemExit( raise SystemExit(
"Missing GITEA_ISSUE (or fallback GITEA_TOKEN). " "Missing GITEA_TOKEN. Export the token before creating issues."
"Export the token before creating issues."
) )
return token or "" return token or ""

View File

@ -5,9 +5,9 @@ description: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条
# Issue - 通用 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”时,执行以下步骤。 当用户调用 `/issue``$issue`,或自然语言要求“查看当前仓库 issue”“看某个 Gitea 仓库的 issue”“查 issue #17”时,执行以下步骤。

View File

@ -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 留言:已完成回归
```

View File

@ -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<host>[^:]+):(?P<path>.+?)(?:\.git)?/?$")
REPO_PATH_RE = re.compile(r"^(?P<owner>[^/]+)/(?P<repo>[^/]+)$")
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)

View File

@ -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())

View File

@ -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())

View File

@ -7,7 +7,7 @@ description: 归集证据并把问题拆成 1 到多张 Gitea issue支持从
> **定位**:不是“查看 issue”而是把一个真实问题沉淀成可执行的 Gitea issue。适合“线上出 bug 了”“要把一个需求拆成工单”“需要补齐工程治理任务”这类场景。 > **定位**:不是“查看 issue”而是把一个真实问题沉淀成可执行的 Gitea issue。适合“线上出 bug 了”“要把一个需求拆成工单”“需要补齐工程治理任务”这类场景。
> >
> 如果用户只是想查 issue 列表或详情,用 `issue` > 如果用户只是想查 issue 列表或详情,用 `issue`如果用户想通过统一入口完成 issue / push / PR 组合操作,用 `gitea`
当用户调用 `/issue-drive``$issue-drive`,或自然语言要求“拆 issue”“提 issue”“把问题沉淀成 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`:必需,创建 issue 时使用
- `GITEA_TOKEN``GITEA_ISSUE` 缺失时回退使用
- `GITEA_BASE_URL`:可选;仓库简写或 SSH origin 时推荐配置 - `GITEA_BASE_URL`:可选;仓库简写或 SSH origin 时推荐配置
如果 `GITEA_ISSUE``GITEA_TOKEN` 都缺失,输出: 如果缺少 `GITEA_TOKEN`,输出:
```bash ```bash
❌ 缺少 GITEA_ISSUE / GITEA_TOKEN ❌ 缺少 GITEA_TOKEN
请先在当前 shell 或 .env 中配置: 请先在当前 shell 或 .env 中配置:
export GITEA_ISSUE=your_gitea_write_token
export GITEA_TOKEN=your_gitea_token export GITEA_TOKEN=your_gitea_token
``` ```
@ -151,7 +149,7 @@ JSON 结构固定为:
- `repo_url` 可省略;脚本会按 `--repo-url` > `spec.repo_url` > `spec.repo` > 当前仓库 `origin` 的顺序解析 - `repo_url` 可省略;脚本会按 `--repo-url` > `spec.repo_url` > `spec.repo` > 当前仓库 `origin` 的顺序解析
- `spec.repo` 允许写 `owner/repo`,但需要 `GITEA_BASE_URL` - `spec.repo` 允许写 `owner/repo`,但需要 `GITEA_BASE_URL`
- Token 优先读取 `GITEA_ISSUE`,兼容回退 `GITEA_TOKEN` - Token 固定读取 `GITEA_TOKEN`
- `labels` 写标签名,脚本会自动解析成远端 label id - `labels` 写标签名,脚本会自动解析成远端 label id
- 远端不存在的标签会告警并自动跳过,不中断整个批次 - 远端不存在的标签会告警并自动跳过,不中断整个批次
- 先跑 `--dry-run`,确认 payload 和目标仓库无误后再正式创建 - 先跑 `--dry-run`,确认 payload 和目标仓库无误后再正式创建

View File

@ -43,11 +43,10 @@ def parse_args() -> argparse.Namespace:
def load_token(required: bool = True) -> str: 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: if not token and required:
raise SystemExit( raise SystemExit(
"Missing GITEA_ISSUE (or fallback GITEA_TOKEN). " "Missing GITEA_TOKEN. Export the token before creating issues."
"Export the token before creating issues."
) )
return token or "" return token or ""

View File

@ -5,9 +5,9 @@ description: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条
# Issue - 通用 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”时,执行以下步骤。 当用户调用 `/issue``$issue`,或自然语言要求“查看当前仓库 issue”“看某个 Gitea 仓库的 issue”“查 issue #17”时,执行以下步骤。

View File

@ -28,6 +28,7 @@
- `doc`: 渐进式文档生成器。首次只写精炼梗概≤300字后续通过迭代不断完善。 (file: `./.codex/skills/doc/SKILL.md`) - `doc`: 渐进式文档生成器。首次只写精炼梗概≤300字后续通过迭代不断完善。 (file: `./.codex/skills/doc/SKILL.md`)
- `update`: 收集用户反馈并更新最近使用的 skill。别名`up`。 (file: `./.codex/skills/up/SKILL.md`) - `update`: 收集用户反馈并更新最近使用的 skill。别名`up`。 (file: `./.codex/skills/up/SKILL.md`)
- `deploy`: Drone CI + 服务器 CD 全流程引导:从基础设施检查到生成配置文件到验证部署,交互式完成。 (file: `./.codex/skills/deploy/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`: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条详情,支持自动识别 git origin、用户指定仓库和格式化输出。 (file: `./.codex/skills/issue/SKILL.md`)
- `issue-drive`: 归集证据并把问题拆成 1 到多张 Gitea issue支持从当前仓库 origin 自动识别仓库或用户显式指定。 (file: `./.codex/skills/issue-drive/SKILL.md`) - `issue-drive`: 归集证据并把问题拆成 1 到多张 Gitea issue支持从当前仓库 origin 自动识别仓库或用户显式指定。 (file: `./.codex/skills/issue-drive/SKILL.md`)
- `changelog`: 一键发版:生成更新日志 → commit → 打 tag全流程自动化。 (file: `./.codex/skills/changelog/SKILL.md`) - `changelog`: 一键发版:生成更新日志 → commit → 打 tag全流程自动化。 (file: `./.codex/skills/changelog/SKILL.md`)

View File

@ -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 留言:已完成回归
```

View File

@ -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<host>[^:]+):(?P<path>.+?)(?:\.git)?/?$")
REPO_PATH_RE = re.compile(r"^(?P<owner>[^/]+)/(?P<repo>[^/]+)$")
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)

View File

@ -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())

View File

@ -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())

View File

@ -7,7 +7,7 @@ description: 归集证据并把问题拆成 1 到多张 Gitea issue支持从
> **定位**:不是“查看 issue”而是把一个真实问题沉淀成可执行的 Gitea issue。适合“线上出 bug 了”“要把一个需求拆成工单”“需要补齐工程治理任务”这类场景。 > **定位**:不是“查看 issue”而是把一个真实问题沉淀成可执行的 Gitea issue。适合“线上出 bug 了”“要把一个需求拆成工单”“需要补齐工程治理任务”这类场景。
> >
> 如果用户只是想查 issue 列表或详情,用 `issue` > 如果用户只是想查 issue 列表或详情,用 `issue`如果用户想通过统一入口完成 issue / push / PR 组合操作,用 `gitea`
当用户调用 `/issue-drive``$issue-drive`,或自然语言要求“拆 issue”“提 issue”“把问题沉淀成 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`:必需,创建 issue 时使用
- `GITEA_TOKEN``GITEA_ISSUE` 缺失时回退使用
- `GITEA_BASE_URL`:可选;仓库简写或 SSH origin 时推荐配置 - `GITEA_BASE_URL`:可选;仓库简写或 SSH origin 时推荐配置
如果 `GITEA_ISSUE``GITEA_TOKEN` 都缺失,输出: 如果缺少 `GITEA_TOKEN`,输出:
```bash ```bash
❌ 缺少 GITEA_ISSUE / GITEA_TOKEN ❌ 缺少 GITEA_TOKEN
请先在当前 shell 或 .env 中配置: 请先在当前 shell 或 .env 中配置:
export GITEA_ISSUE=your_gitea_write_token
export GITEA_TOKEN=your_gitea_token export GITEA_TOKEN=your_gitea_token
``` ```
@ -151,7 +149,7 @@ JSON 结构固定为:
- `repo_url` 可省略;脚本会按 `--repo-url` > `spec.repo_url` > `spec.repo` > 当前仓库 `origin` 的顺序解析 - `repo_url` 可省略;脚本会按 `--repo-url` > `spec.repo_url` > `spec.repo` > 当前仓库 `origin` 的顺序解析
- `spec.repo` 允许写 `owner/repo`,但需要 `GITEA_BASE_URL` - `spec.repo` 允许写 `owner/repo`,但需要 `GITEA_BASE_URL`
- Token 优先读取 `GITEA_ISSUE`,兼容回退 `GITEA_TOKEN` - Token 固定读取 `GITEA_TOKEN`
- `labels` 写标签名,脚本会自动解析成远端 label id - `labels` 写标签名,脚本会自动解析成远端 label id
- 远端不存在的标签会告警并自动跳过,不中断整个批次 - 远端不存在的标签会告警并自动跳过,不中断整个批次
- 先跑 `--dry-run`,确认 payload 和目标仓库无误后再正式创建 - 先跑 `--dry-run`,确认 payload 和目标仓库无误后再正式创建

View File

@ -43,11 +43,10 @@ def parse_args() -> argparse.Namespace:
def load_token(required: bool = True) -> str: 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: if not token and required:
raise SystemExit( raise SystemExit(
"Missing GITEA_ISSUE (or fallback GITEA_TOKEN). " "Missing GITEA_TOKEN. Export the token before creating issues."
"Export the token before creating issues."
) )
return token or "" return token or ""

View File

@ -5,9 +5,9 @@ description: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条
# Issue - 通用 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”时,执行以下步骤。 当用户调用 `/issue``$issue`,或自然语言要求“查看当前仓库 issue”“看某个 Gitea 仓库的 issue”“查 issue #17”时,执行以下步骤。

10
.gitignore vendored
View File

@ -15,10 +15,20 @@ write-skills/
!.agents/skills/issue-drive/SKILL.md !.agents/skills/issue-drive/SKILL.md
!.agents/skills/issue-drive/scripts/ !.agents/skills/issue-drive/scripts/
!.agents/skills/issue-drive/scripts/create_gitea_issues.py !.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 # macOS
.DS_Store .DS_Store
# Python
__pycache__/
*.pyc
# IDE # IDE
.idea/ .idea/
.vscode/ .vscode/

View File

@ -24,6 +24,7 @@
- `doc`: 渐进式文档生成器。首次只写精炼梗概≤300字后续通过迭代不断完善。 (file: `./.codex/skills/doc/SKILL.md`) - `doc`: 渐进式文档生成器。首次只写精炼梗概≤300字后续通过迭代不断完善。 (file: `./.codex/skills/doc/SKILL.md`)
- `update`: 收集用户反馈并更新最近使用的 skill。别名`up`。 (file: `./.codex/skills/up/SKILL.md`) - `update`: 收集用户反馈并更新最近使用的 skill。别名`up`。 (file: `./.codex/skills/up/SKILL.md`)
- `deploy`: Drone CI + 服务器 CD 全流程引导:从基础设施检查到生成配置文件到验证部署,交互式完成。 (file: `./.codex/skills/deploy/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`: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条详情,支持自动识别 git origin、用户指定仓库和格式化输出。 (file: `./.codex/skills/issue/SKILL.md`)
- `issue-drive`: 归集证据并把问题拆成 1 到多张 Gitea issue支持从当前仓库 origin 自动识别仓库或用户显式指定。 (file: `./.codex/skills/issue-drive/SKILL.md`) - `issue-drive`: 归集证据并把问题拆成 1 到多张 Gitea issue支持从当前仓库 origin 自动识别仓库或用户显式指定。 (file: `./.codex/skills/issue-drive/SKILL.md`)
- `changelog`: 一键发版:生成更新日志 → commit → 打 tag全流程自动化。 (file: `./.codex/skills/changelog/SKILL.md`) - `changelog`: 一键发版:生成更新日志 → commit → 打 tag全流程自动化。 (file: `./.codex/skills/changelog/SKILL.md`)

View File

@ -28,6 +28,7 @@
- `doc`: 渐进式文档生成器。首次只写精炼梗概≤300字后续通过迭代不断完善。 (file: `./.codex/skills/doc/SKILL.md`) - `doc`: 渐进式文档生成器。首次只写精炼梗概≤300字后续通过迭代不断完善。 (file: `./.codex/skills/doc/SKILL.md`)
- `update`: 收集用户反馈并更新最近使用的 skill。别名`up`。 (file: `./.codex/skills/up/SKILL.md`) - `update`: 收集用户反馈并更新最近使用的 skill。别名`up`。 (file: `./.codex/skills/up/SKILL.md`)
- `deploy`: Drone CI + 服务器 CD 全流程引导:从基础设施检查到生成配置文件到验证部署,交互式完成。 (file: `./.codex/skills/deploy/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`: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条详情,支持自动识别 git origin、用户指定仓库和格式化输出。 (file: `./.codex/skills/issue/SKILL.md`)
- `issue-drive`: 归集证据并把问题拆成 1 到多张 Gitea issue支持从当前仓库 origin 自动识别仓库或用户显式指定。 (file: `./.codex/skills/issue-drive/SKILL.md`) - `issue-drive`: 归集证据并把问题拆成 1 到多张 Gitea issue支持从当前仓库 origin 自动识别仓库或用户显式指定。 (file: `./.codex/skills/issue-drive/SKILL.md`)
- `changelog`: 一键发版:生成更新日志 → commit → 打 tag全流程自动化。 (file: `./.codex/skills/changelog/SKILL.md`) - `changelog`: 一键发版:生成更新日志 → commit → 打 tag全流程自动化。 (file: `./.codex/skills/changelog/SKILL.md`)

View File

@ -47,8 +47,9 @@ RequirementsDoc ──▶ PRD ──▶ FeatureSummary ──▶ DevelopmentPlan
| | `doc` | `/doc` | `/doc` | 渐进式文档生成器,先写梗概再迭代完善 | | | `doc` | `/doc` | `/doc` | 渐进式文档生成器,先写梗概再迭代完善 |
| | `update` | `/up` | `/up` | Skill 升级优化 | | | `update` | `/up` | `/up` | Skill 升级优化 |
| | `deploy` | `/deploy` | `/deploy` | Drone CI/CD 全流程部署引导 | | | `deploy` | `/deploy` | `/deploy` | Drone CI/CD 全流程部署引导 |
| | `issue` | `/issue` | `/issue` | 通用 Gitea issue 查询(支持当前仓库自动识别) | | | `gitea` | `/gitea` | `/gitea` | 统一 Gitea 入口issue / push / PR |
| | `issue-drive` | `/issue-drive` | `/issue-drive` | 通用 Gitea issue 拆单与批量创建 | | | `issue` | `/issue` | `/issue` | Gitea issue 只读专用入口(支持当前仓库自动识别) |
| | `issue-drive` | `/issue-drive` | `/issue-drive` | Gitea issue 拆单与批量创建专用入口 |
| | `changelog` | `/changelog` | `/changelog` | 一键发版(日志 + commit + tag | | | `changelog` | `/changelog` | `/changelog` | 一键发版(日志 + commit + tag |
> Codex 兼容历史 `$skill` 写法,但本文档统一以 `/skill` 作为主入口。 > Codex 兼容历史 `$skill` 写法,但本文档统一以 `/skill` 作为主入口。
@ -147,22 +148,58 @@ Codex:
请用 wf skill 根据 PRD 生成 FeatureSummary 请用 wf skill 根据 PRD 生成 FeatureSummary
请用 go skill 按 doc/tasks.md 执行未完成任务 请用 go skill 按 doc/tasks.md 执行未完成任务
请用 doc skill 为认证模块写一份 300 字以内的使用说明 请用 doc skill 为认证模块写一份 300 字以内的使用说明
请用 issue skill 查看当前仓库的 open issues 请用 gitea skill 看当前仓库 open issues
请用 gitea skill 给当前分支开 PR 到 main
请用 gitea skill push 当前分支到远端
请用 issue-drive skill 把当前 bug 拆成两张 Gitea issue 请用 issue-drive skill 把当前 bug 拆成两张 Gitea issue
``` ```
### 查询 Gitea Issues ### 统一 Gitea 入口
先配置环境变量: 先配置环境变量:
```bash ```bash
export GITEA_TOKEN=your_gitea_token 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 时推荐配置 export GITEA_BASE_URL=https://git.example.com # 可选owner/repo 简写或 SSH origin 时推荐配置
``` ```
Claude Code: 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 ```bash
/issue /issue
/issue 7 /issue 7
@ -183,13 +220,7 @@ Codex:
/issue https://git.example.com/owner/repo 7 /issue https://git.example.com/owner/repo 7
``` ```
说明: ### 专用入口:创建 Gitea Issues
- 直接 `/issue``/issue 7`skill 会优先从当前项目的 `git origin` 自动识别仓库
- 传 `owner/repo` 时,需要 `GITEA_BASE_URL`
- 当前仓库 `origin` 是 SSH 地址时,建议配置 `GITEA_BASE_URL`,避免 Web/API 域名与 SSH 域名不一致
### 创建 Gitea Issues
Claude Code: Claude Code:
@ -250,6 +281,7 @@ your-project/
│ ├── rp/ │ ├── rp/
│ ├── ... │ ├── ...
│ ├── iter/ │ ├── iter/
│ ├── gitea/
│ └── issue-drive/ │ └── issue-drive/
├── .claude/ ├── .claude/
│ └── skills/ # ← Claude Code Skills 安装位置 │ └── skills/ # ← Claude Code Skills 安装位置
@ -257,6 +289,7 @@ your-project/
│ ├── rp/ │ ├── rp/
│ ├── ... │ ├── ...
│ ├── iter/ │ ├── iter/
│ ├── gitea/
│ └── issue-drive/ │ └── issue-drive/
├── doc/ # ← 文档输出位置 ├── doc/ # ← 文档输出位置
│ ├── RequirementsDoc.md │ ├── RequirementsDoc.md