Add unified Gitea skill workflows
This commit is contained in:
parent
22765a2917
commit
c27b85193f
144
.agents/skills/gitea/SKILL.md
Normal file
144
.agents/skills/gitea/SKILL.md
Normal 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 留言:已完成回归
|
||||||
|
```
|
||||||
334
.agents/skills/gitea/scripts/common.py
Normal file
334
.agents/skills/gitea/scripts/common.py
Normal 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)
|
||||||
166
.agents/skills/gitea/scripts/pr_gitea.py
Normal file
166
.agents/skills/gitea/scripts/pr_gitea.py
Normal 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())
|
||||||
236
.agents/skills/gitea/scripts/push_gitea.py
Normal file
236
.agents/skills/gitea/scripts/push_gitea.py
Normal 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())
|
||||||
@ -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 和目标仓库无误后再正式创建
|
||||||
|
|||||||
@ -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 ""
|
||||||
|
|
||||||
|
|||||||
@ -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”时,执行以下步骤。
|
||||||
|
|
||||||
|
|||||||
144
.claude/skills/gitea/SKILL.md
Normal file
144
.claude/skills/gitea/SKILL.md
Normal 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 留言:已完成回归
|
||||||
|
```
|
||||||
334
.claude/skills/gitea/scripts/common.py
Normal file
334
.claude/skills/gitea/scripts/common.py
Normal 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)
|
||||||
166
.claude/skills/gitea/scripts/pr_gitea.py
Normal file
166
.claude/skills/gitea/scripts/pr_gitea.py
Normal 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())
|
||||||
236
.claude/skills/gitea/scripts/push_gitea.py
Normal file
236
.claude/skills/gitea/scripts/push_gitea.py
Normal 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())
|
||||||
@ -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 和目标仓库无误后再正式创建
|
||||||
|
|||||||
@ -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 ""
|
||||||
|
|
||||||
|
|||||||
@ -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”时,执行以下步骤。
|
||||||
|
|
||||||
|
|||||||
@ -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`)
|
||||||
|
|||||||
144
.codex/skills/gitea/SKILL.md
Normal file
144
.codex/skills/gitea/SKILL.md
Normal 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 留言:已完成回归
|
||||||
|
```
|
||||||
334
.codex/skills/gitea/scripts/common.py
Normal file
334
.codex/skills/gitea/scripts/common.py
Normal 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)
|
||||||
166
.codex/skills/gitea/scripts/pr_gitea.py
Normal file
166
.codex/skills/gitea/scripts/pr_gitea.py
Normal 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())
|
||||||
236
.codex/skills/gitea/scripts/push_gitea.py
Normal file
236
.codex/skills/gitea/scripts/push_gitea.py
Normal 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())
|
||||||
@ -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 和目标仓库无误后再正式创建
|
||||||
|
|||||||
@ -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 ""
|
||||||
|
|
||||||
|
|||||||
@ -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
10
.gitignore
vendored
@ -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/
|
||||||
|
|||||||
@ -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`)
|
||||||
|
|||||||
@ -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`)
|
||||||
|
|||||||
57
README.md
57
README.md
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user