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 列表或详情,用 `issue`。
|
||||
> 如果用户只是想查 issue 列表或详情,用 `issue`。如果用户想通过统一入口完成 issue / push / PR 组合操作,用 `gitea`。
|
||||
|
||||
当用户调用 `/issue-drive`、`$issue-drive`,或自然语言要求“拆 issue”“提 issue”“把问题沉淀成 Gitea 工单”时,执行以下步骤。
|
||||
|
||||
@ -45,17 +45,15 @@ export GITEA_BASE_URL=https://git.example.com
|
||||
|
||||
先读取环境变量:
|
||||
|
||||
- `GITEA_ISSUE`:创建 issue 时优先使用
|
||||
- `GITEA_TOKEN`:`GITEA_ISSUE` 缺失时回退使用
|
||||
- `GITEA_TOKEN`:必需,创建 issue 时使用
|
||||
- `GITEA_BASE_URL`:可选;仓库简写或 SSH origin 时推荐配置
|
||||
|
||||
如果 `GITEA_ISSUE` 和 `GITEA_TOKEN` 都缺失,输出:
|
||||
如果缺少 `GITEA_TOKEN`,输出:
|
||||
|
||||
```bash
|
||||
❌ 缺少 GITEA_ISSUE / GITEA_TOKEN
|
||||
❌ 缺少 GITEA_TOKEN
|
||||
|
||||
请先在当前 shell 或 .env 中配置:
|
||||
export GITEA_ISSUE=your_gitea_write_token
|
||||
export GITEA_TOKEN=your_gitea_token
|
||||
```
|
||||
|
||||
@ -151,7 +149,7 @@ JSON 结构固定为:
|
||||
|
||||
- `repo_url` 可省略;脚本会按 `--repo-url` > `spec.repo_url` > `spec.repo` > 当前仓库 `origin` 的顺序解析
|
||||
- `spec.repo` 允许写 `owner/repo`,但需要 `GITEA_BASE_URL`
|
||||
- Token 优先读取 `GITEA_ISSUE`,兼容回退 `GITEA_TOKEN`
|
||||
- Token 固定读取 `GITEA_TOKEN`
|
||||
- `labels` 写标签名,脚本会自动解析成远端 label id
|
||||
- 远端不存在的标签会告警并自动跳过,不中断整个批次
|
||||
- 先跑 `--dry-run`,确认 payload 和目标仓库无误后再正式创建
|
||||
|
||||
@ -43,11 +43,10 @@ def parse_args() -> argparse.Namespace:
|
||||
|
||||
|
||||
def load_token(required: bool = True) -> str:
|
||||
token = os.getenv("GITEA_ISSUE") or os.getenv("GITEA_TOKEN")
|
||||
token = os.getenv("GITEA_TOKEN")
|
||||
if not token and required:
|
||||
raise SystemExit(
|
||||
"Missing GITEA_ISSUE (or fallback GITEA_TOKEN). "
|
||||
"Export the token before creating issues."
|
||||
"Missing GITEA_TOKEN. Export the token before creating issues."
|
||||
)
|
||||
return token or ""
|
||||
|
||||
|
||||
@ -5,9 +5,9 @@ description: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条
|
||||
|
||||
# Issue - 通用 Gitea Issue 查看
|
||||
|
||||
> **定位**:这是只读 skill。用于查看当前仓库或指定仓库的 issue 列表、单条详情和评论,不负责拆单和创建工单。
|
||||
> **定位**:这是只读 skill。用于查看当前仓库或指定仓库的 issue 列表、单条详情和评论,不负责拆单、创建工单、push 或 PR。
|
||||
>
|
||||
> 如果用户要做的是“把问题拆成 issue 并实际创建工单”,不要使用本 skill,改用 `issue-drive`。
|
||||
> 如果用户要做的是“把问题拆成 issue 并实际创建工单”,改用 `issue-drive`。如果用户想用统一入口处理 Gitea 相关任务,改用 `gitea`。
|
||||
|
||||
当用户调用 `/issue`、`$issue`,或自然语言要求“查看当前仓库 issue”“看某个 Gitea 仓库的 issue”“查 issue #17”时,执行以下步骤。
|
||||
|
||||
|
||||
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 列表或详情,用 `issue`。
|
||||
> 如果用户只是想查 issue 列表或详情,用 `issue`。如果用户想通过统一入口完成 issue / push / PR 组合操作,用 `gitea`。
|
||||
|
||||
当用户调用 `/issue-drive`、`$issue-drive`,或自然语言要求“拆 issue”“提 issue”“把问题沉淀成 Gitea 工单”时,执行以下步骤。
|
||||
|
||||
@ -45,17 +45,15 @@ export GITEA_BASE_URL=https://git.example.com
|
||||
|
||||
先读取环境变量:
|
||||
|
||||
- `GITEA_ISSUE`:创建 issue 时优先使用
|
||||
- `GITEA_TOKEN`:`GITEA_ISSUE` 缺失时回退使用
|
||||
- `GITEA_TOKEN`:必需,创建 issue 时使用
|
||||
- `GITEA_BASE_URL`:可选;仓库简写或 SSH origin 时推荐配置
|
||||
|
||||
如果 `GITEA_ISSUE` 和 `GITEA_TOKEN` 都缺失,输出:
|
||||
如果缺少 `GITEA_TOKEN`,输出:
|
||||
|
||||
```bash
|
||||
❌ 缺少 GITEA_ISSUE / GITEA_TOKEN
|
||||
❌ 缺少 GITEA_TOKEN
|
||||
|
||||
请先在当前 shell 或 .env 中配置:
|
||||
export GITEA_ISSUE=your_gitea_write_token
|
||||
export GITEA_TOKEN=your_gitea_token
|
||||
```
|
||||
|
||||
@ -151,7 +149,7 @@ JSON 结构固定为:
|
||||
|
||||
- `repo_url` 可省略;脚本会按 `--repo-url` > `spec.repo_url` > `spec.repo` > 当前仓库 `origin` 的顺序解析
|
||||
- `spec.repo` 允许写 `owner/repo`,但需要 `GITEA_BASE_URL`
|
||||
- Token 优先读取 `GITEA_ISSUE`,兼容回退 `GITEA_TOKEN`
|
||||
- Token 固定读取 `GITEA_TOKEN`
|
||||
- `labels` 写标签名,脚本会自动解析成远端 label id
|
||||
- 远端不存在的标签会告警并自动跳过,不中断整个批次
|
||||
- 先跑 `--dry-run`,确认 payload 和目标仓库无误后再正式创建
|
||||
|
||||
@ -43,11 +43,10 @@ def parse_args() -> argparse.Namespace:
|
||||
|
||||
|
||||
def load_token(required: bool = True) -> str:
|
||||
token = os.getenv("GITEA_ISSUE") or os.getenv("GITEA_TOKEN")
|
||||
token = os.getenv("GITEA_TOKEN")
|
||||
if not token and required:
|
||||
raise SystemExit(
|
||||
"Missing GITEA_ISSUE (or fallback GITEA_TOKEN). "
|
||||
"Export the token before creating issues."
|
||||
"Missing GITEA_TOKEN. Export the token before creating issues."
|
||||
)
|
||||
return token or ""
|
||||
|
||||
|
||||
@ -5,9 +5,9 @@ description: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条
|
||||
|
||||
# Issue - 通用 Gitea Issue 查看
|
||||
|
||||
> **定位**:这是只读 skill。用于查看当前仓库或指定仓库的 issue 列表、单条详情和评论,不负责拆单和创建工单。
|
||||
> **定位**:这是只读 skill。用于查看当前仓库或指定仓库的 issue 列表、单条详情和评论,不负责拆单、创建工单、push 或 PR。
|
||||
>
|
||||
> 如果用户要做的是“把问题拆成 issue 并实际创建工单”,不要使用本 skill,改用 `issue-drive`。
|
||||
> 如果用户要做的是“把问题拆成 issue 并实际创建工单”,改用 `issue-drive`。如果用户想用统一入口处理 Gitea 相关任务,改用 `gitea`。
|
||||
|
||||
当用户调用 `/issue`、`$issue`,或自然语言要求“查看当前仓库 issue”“看某个 Gitea 仓库的 issue”“查 issue #17”时,执行以下步骤。
|
||||
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
- `doc`: 渐进式文档生成器。首次只写精炼梗概(≤300字),后续通过迭代不断完善。 (file: `./.codex/skills/doc/SKILL.md`)
|
||||
- `update`: 收集用户反馈并更新最近使用的 skill。别名:`up`。 (file: `./.codex/skills/up/SKILL.md`)
|
||||
- `deploy`: Drone CI + 服务器 CD 全流程引导:从基础设施检查到生成配置文件到验证部署,交互式完成。 (file: `./.codex/skills/deploy/SKILL.md`)
|
||||
- `gitea`: 统一 Gitea 总入口,支持 issue 查询、issue 拆单创建、git push 和 PR 基础操作,优先从当前仓库 origin 自动识别目标仓库。 (file: `./.codex/skills/gitea/SKILL.md`)
|
||||
- `issue`: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条详情,支持自动识别 git origin、用户指定仓库和格式化输出。 (file: `./.codex/skills/issue/SKILL.md`)
|
||||
- `issue-drive`: 归集证据并把问题拆成 1 到多张 Gitea issue,支持从当前仓库 origin 自动识别仓库或用户显式指定。 (file: `./.codex/skills/issue-drive/SKILL.md`)
|
||||
- `changelog`: 一键发版:生成更新日志 → commit → 打 tag,全流程自动化。 (file: `./.codex/skills/changelog/SKILL.md`)
|
||||
|
||||
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 列表或详情,用 `issue`。
|
||||
> 如果用户只是想查 issue 列表或详情,用 `issue`。如果用户想通过统一入口完成 issue / push / PR 组合操作,用 `gitea`。
|
||||
|
||||
当用户调用 `/issue-drive`、`$issue-drive`,或自然语言要求“拆 issue”“提 issue”“把问题沉淀成 Gitea 工单”时,执行以下步骤。
|
||||
|
||||
@ -45,17 +45,15 @@ export GITEA_BASE_URL=https://git.example.com
|
||||
|
||||
先读取环境变量:
|
||||
|
||||
- `GITEA_ISSUE`:创建 issue 时优先使用
|
||||
- `GITEA_TOKEN`:`GITEA_ISSUE` 缺失时回退使用
|
||||
- `GITEA_TOKEN`:必需,创建 issue 时使用
|
||||
- `GITEA_BASE_URL`:可选;仓库简写或 SSH origin 时推荐配置
|
||||
|
||||
如果 `GITEA_ISSUE` 和 `GITEA_TOKEN` 都缺失,输出:
|
||||
如果缺少 `GITEA_TOKEN`,输出:
|
||||
|
||||
```bash
|
||||
❌ 缺少 GITEA_ISSUE / GITEA_TOKEN
|
||||
❌ 缺少 GITEA_TOKEN
|
||||
|
||||
请先在当前 shell 或 .env 中配置:
|
||||
export GITEA_ISSUE=your_gitea_write_token
|
||||
export GITEA_TOKEN=your_gitea_token
|
||||
```
|
||||
|
||||
@ -151,7 +149,7 @@ JSON 结构固定为:
|
||||
|
||||
- `repo_url` 可省略;脚本会按 `--repo-url` > `spec.repo_url` > `spec.repo` > 当前仓库 `origin` 的顺序解析
|
||||
- `spec.repo` 允许写 `owner/repo`,但需要 `GITEA_BASE_URL`
|
||||
- Token 优先读取 `GITEA_ISSUE`,兼容回退 `GITEA_TOKEN`
|
||||
- Token 固定读取 `GITEA_TOKEN`
|
||||
- `labels` 写标签名,脚本会自动解析成远端 label id
|
||||
- 远端不存在的标签会告警并自动跳过,不中断整个批次
|
||||
- 先跑 `--dry-run`,确认 payload 和目标仓库无误后再正式创建
|
||||
|
||||
@ -43,11 +43,10 @@ def parse_args() -> argparse.Namespace:
|
||||
|
||||
|
||||
def load_token(required: bool = True) -> str:
|
||||
token = os.getenv("GITEA_ISSUE") or os.getenv("GITEA_TOKEN")
|
||||
token = os.getenv("GITEA_TOKEN")
|
||||
if not token and required:
|
||||
raise SystemExit(
|
||||
"Missing GITEA_ISSUE (or fallback GITEA_TOKEN). "
|
||||
"Export the token before creating issues."
|
||||
"Missing GITEA_TOKEN. Export the token before creating issues."
|
||||
)
|
||||
return token or ""
|
||||
|
||||
|
||||
@ -5,9 +5,9 @@ description: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条
|
||||
|
||||
# Issue - 通用 Gitea Issue 查看
|
||||
|
||||
> **定位**:这是只读 skill。用于查看当前仓库或指定仓库的 issue 列表、单条详情和评论,不负责拆单和创建工单。
|
||||
> **定位**:这是只读 skill。用于查看当前仓库或指定仓库的 issue 列表、单条详情和评论,不负责拆单、创建工单、push 或 PR。
|
||||
>
|
||||
> 如果用户要做的是“把问题拆成 issue 并实际创建工单”,不要使用本 skill,改用 `issue-drive`。
|
||||
> 如果用户要做的是“把问题拆成 issue 并实际创建工单”,改用 `issue-drive`。如果用户想用统一入口处理 Gitea 相关任务,改用 `gitea`。
|
||||
|
||||
当用户调用 `/issue`、`$issue`,或自然语言要求“查看当前仓库 issue”“看某个 Gitea 仓库的 issue”“查 issue #17”时,执行以下步骤。
|
||||
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -15,10 +15,20 @@ write-skills/
|
||||
!.agents/skills/issue-drive/SKILL.md
|
||||
!.agents/skills/issue-drive/scripts/
|
||||
!.agents/skills/issue-drive/scripts/create_gitea_issues.py
|
||||
!.agents/skills/gitea/
|
||||
!.agents/skills/gitea/SKILL.md
|
||||
!.agents/skills/gitea/scripts/
|
||||
!.agents/skills/gitea/scripts/common.py
|
||||
!.agents/skills/gitea/scripts/push_gitea.py
|
||||
!.agents/skills/gitea/scripts/pr_gitea.py
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
- `doc`: 渐进式文档生成器。首次只写精炼梗概(≤300字),后续通过迭代不断完善。 (file: `./.codex/skills/doc/SKILL.md`)
|
||||
- `update`: 收集用户反馈并更新最近使用的 skill。别名:`up`。 (file: `./.codex/skills/up/SKILL.md`)
|
||||
- `deploy`: Drone CI + 服务器 CD 全流程引导:从基础设施检查到生成配置文件到验证部署,交互式完成。 (file: `./.codex/skills/deploy/SKILL.md`)
|
||||
- `gitea`: 统一 Gitea 总入口,支持 issue 查询、issue 拆单创建、git push 和 PR 基础操作,优先从当前仓库 origin 自动识别目标仓库。 (file: `./.codex/skills/gitea/SKILL.md`)
|
||||
- `issue`: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条详情,支持自动识别 git origin、用户指定仓库和格式化输出。 (file: `./.codex/skills/issue/SKILL.md`)
|
||||
- `issue-drive`: 归集证据并把问题拆成 1 到多张 Gitea issue,支持从当前仓库 origin 自动识别仓库或用户显式指定。 (file: `./.codex/skills/issue-drive/SKILL.md`)
|
||||
- `changelog`: 一键发版:生成更新日志 → commit → 打 tag,全流程自动化。 (file: `./.codex/skills/changelog/SKILL.md`)
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
- `doc`: 渐进式文档生成器。首次只写精炼梗概(≤300字),后续通过迭代不断完善。 (file: `./.codex/skills/doc/SKILL.md`)
|
||||
- `update`: 收集用户反馈并更新最近使用的 skill。别名:`up`。 (file: `./.codex/skills/up/SKILL.md`)
|
||||
- `deploy`: Drone CI + 服务器 CD 全流程引导:从基础设施检查到生成配置文件到验证部署,交互式完成。 (file: `./.codex/skills/deploy/SKILL.md`)
|
||||
- `gitea`: 统一 Gitea 总入口,支持 issue 查询、issue 拆单创建、git push 和 PR 基础操作,优先从当前仓库 origin 自动识别目标仓库。 (file: `./.codex/skills/gitea/SKILL.md`)
|
||||
- `issue`: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条详情,支持自动识别 git origin、用户指定仓库和格式化输出。 (file: `./.codex/skills/issue/SKILL.md`)
|
||||
- `issue-drive`: 归集证据并把问题拆成 1 到多张 Gitea issue,支持从当前仓库 origin 自动识别仓库或用户显式指定。 (file: `./.codex/skills/issue-drive/SKILL.md`)
|
||||
- `changelog`: 一键发版:生成更新日志 → commit → 打 tag,全流程自动化。 (file: `./.codex/skills/changelog/SKILL.md`)
|
||||
|
||||
57
README.md
57
README.md
@ -47,8 +47,9 @@ RequirementsDoc ──▶ PRD ──▶ FeatureSummary ──▶ DevelopmentPlan
|
||||
| | `doc` | `/doc` | `/doc` | 渐进式文档生成器,先写梗概再迭代完善 |
|
||||
| | `update` | `/up` | `/up` | Skill 升级优化 |
|
||||
| | `deploy` | `/deploy` | `/deploy` | Drone CI/CD 全流程部署引导 |
|
||||
| | `issue` | `/issue` | `/issue` | 通用 Gitea issue 查询(支持当前仓库自动识别) |
|
||||
| | `issue-drive` | `/issue-drive` | `/issue-drive` | 通用 Gitea issue 拆单与批量创建 |
|
||||
| | `gitea` | `/gitea` | `/gitea` | 统一 Gitea 入口(issue / push / PR) |
|
||||
| | `issue` | `/issue` | `/issue` | Gitea issue 只读专用入口(支持当前仓库自动识别) |
|
||||
| | `issue-drive` | `/issue-drive` | `/issue-drive` | Gitea issue 拆单与批量创建专用入口 |
|
||||
| | `changelog` | `/changelog` | `/changelog` | 一键发版(日志 + commit + tag) |
|
||||
|
||||
> Codex 兼容历史 `$skill` 写法,但本文档统一以 `/skill` 作为主入口。
|
||||
@ -147,22 +148,58 @@ Codex:
|
||||
请用 wf skill 根据 PRD 生成 FeatureSummary
|
||||
请用 go skill 按 doc/tasks.md 执行未完成任务
|
||||
请用 doc skill 为认证模块写一份 300 字以内的使用说明
|
||||
请用 issue skill 查看当前仓库的 open issues
|
||||
请用 gitea skill 看当前仓库 open issues
|
||||
请用 gitea skill 给当前分支开 PR 到 main
|
||||
请用 gitea skill push 当前分支到远端
|
||||
请用 issue-drive skill 把当前 bug 拆成两张 Gitea issue
|
||||
```
|
||||
|
||||
### 查询 Gitea Issues
|
||||
### 统一 Gitea 入口
|
||||
|
||||
先配置环境变量:
|
||||
|
||||
```bash
|
||||
export GITEA_TOKEN=your_gitea_token
|
||||
export GITEA_ISSUE=your_gitea_write_token # 可选,写 issue 时优先使用
|
||||
export GITEA_BASE_URL=https://git.example.com # 可选;owner/repo 简写或 SSH origin 时推荐配置
|
||||
```
|
||||
|
||||
Claude Code:
|
||||
|
||||
```bash
|
||||
/gitea 看当前仓库 open issues
|
||||
/gitea 查 issue 7
|
||||
/gitea 把这个 bug 拆成两张 issue
|
||||
/gitea push
|
||||
/gitea 给当前分支开 PR 到 main
|
||||
/gitea 看当前仓库 open PR
|
||||
/gitea 在 PR 12 留言:已完成回归
|
||||
```
|
||||
|
||||
Codex:
|
||||
|
||||
```text
|
||||
/gitea 看当前仓库 open issues
|
||||
/gitea 查 issue 7
|
||||
/gitea 把这个 bug 拆成两张 issue
|
||||
/gitea push
|
||||
/gitea 给当前分支开 PR 到 main
|
||||
/gitea 看当前仓库 open PR
|
||||
/gitea 在 PR 12 留言:已完成回归
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `gitea` 是统一入口,会按自然语言自动路由到 issue、issue-drive、push 或 PR
|
||||
- push 默认先预检,再执行;只有明确要求 force push 时才会强推
|
||||
- PR v1 只支持同仓库 list / create / comment
|
||||
- 直接 `/gitea 看 issue 7` 或 `/gitea push` 时,skill 会优先从当前项目的 `git origin` 自动识别仓库
|
||||
- 传 `owner/repo` 时,需要 `GITEA_BASE_URL`
|
||||
- 当前仓库 `origin` 是 SSH 地址时,建议配置 `GITEA_BASE_URL`,避免 Web/API 域名与 SSH 域名不一致
|
||||
|
||||
### 专用入口:查询 Gitea Issues
|
||||
|
||||
Claude Code:
|
||||
|
||||
```bash
|
||||
/issue
|
||||
/issue 7
|
||||
@ -183,13 +220,7 @@ Codex:
|
||||
/issue https://git.example.com/owner/repo 7
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 直接 `/issue` 或 `/issue 7` 时,skill 会优先从当前项目的 `git origin` 自动识别仓库
|
||||
- 传 `owner/repo` 时,需要 `GITEA_BASE_URL`
|
||||
- 当前仓库 `origin` 是 SSH 地址时,建议配置 `GITEA_BASE_URL`,避免 Web/API 域名与 SSH 域名不一致
|
||||
|
||||
### 创建 Gitea Issues
|
||||
### 专用入口:创建 Gitea Issues
|
||||
|
||||
Claude Code:
|
||||
|
||||
@ -250,6 +281,7 @@ your-project/
|
||||
│ ├── rp/
|
||||
│ ├── ...
|
||||
│ ├── iter/
|
||||
│ ├── gitea/
|
||||
│ └── issue-drive/
|
||||
├── .claude/
|
||||
│ └── skills/ # ← Claude Code Skills 安装位置
|
||||
@ -257,6 +289,7 @@ your-project/
|
||||
│ ├── rp/
|
||||
│ ├── ...
|
||||
│ ├── iter/
|
||||
│ ├── gitea/
|
||||
│ └── issue-drive/
|
||||
├── doc/ # ← 文档输出位置
|
||||
│ ├── RequirementsDoc.md
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user