From b2c5ef588da1b5ebc1ca4fd500b5194c57adbd82 Mon Sep 17 00:00:00 2001 From: X Date: Fri, 6 Mar 2026 18:27:32 -0800 Subject: [PATCH] Add gitea-repo-sync skill --- README.md | 1 + gitea-repo-sync/SKILL.md | 119 ++++++++ gitea-repo-sync/agents/openai.yaml | 4 + gitea-repo-sync/references/gitea-api-notes.md | 49 ++++ gitea-repo-sync/scripts/gitea_repo_sync.py | 260 ++++++++++++++++++ 5 files changed, 433 insertions(+) create mode 100644 gitea-repo-sync/SKILL.md create mode 100644 gitea-repo-sync/agents/openai.yaml create mode 100644 gitea-repo-sync/references/gitea-api-notes.md create mode 100755 gitea-repo-sync/scripts/gitea_repo_sync.py diff --git a/README.md b/README.md index 1d19297..c0cbd41 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ | `cliproxy-traffic-proxy` | CLIProxy 流量代理相关技能。 | | `cn86-sms-keyword-verification` | 86 手机号 + 关键词验证码流程,基于 LubanSMS API 完成取号、取码、提码、释放号码。 | | `email-verification` | 邮箱验证码获取服务,支持临时邮箱 API 拉取验证码、链接和邮件内容。 | +| `gitea-repo-sync` | 在 Gitea 上创建仓库并把本地项目安全同步过去。 | | `similarweb-analytics` | Similarweb 分析相关技能。 | | `simple-llm` | 轻量 LLM 调用技能。 | | `uiuxmax` | UI/UX 设计辅助技能。 | diff --git a/gitea-repo-sync/SKILL.md b/gitea-repo-sync/SKILL.md new file mode 100644 index 0000000..86ce3f8 --- /dev/null +++ b/gitea-repo-sync/SKILL.md @@ -0,0 +1,119 @@ +--- +name: gitea-repo-sync +description: 在自建或托管 Gitea 上自动创建仓库、初始化或复用本地 Git 项目、配置远程并安全推送分支/标签。适用于“给当前目录建 Gitea 仓库”“同步本地项目到 Gitea”“给组织创建仓库再推送代码”这类请求。 +metadata: + short-description: Create Gitea repositories and sync local projects safely +--- +# Gitea Repo Sync + +在 Gitea 上建仓、连远程、推送代码的标准化技能。 + +优先使用打包好的 `scripts/gitea_repo_sync.py`,不要重复手写零散 `curl` + `git` 命令。脚本会先探测仓库是否已存在,再按需要创建仓库、初始化本地 Git、配置远程并执行 push。 + +## 什么时候用 + +- 用户要把当前目录项目同步到 Gitea +- 用户要在某个 owner 或 organization 下创建新仓库 +- 本地目录还不是 Git 仓库,但需要初始化后推送 +- 需要复用已有远程仓库,而不是盲目重复创建 +- 需要在自动化里稳定处理 token、remote 和 push 顺序 + +## 需要的输入 + +- `GITEA_URL`:例如 `https://git.hk.hao.work` +- `GITEA_TOKEN`:具备建仓和推送权限的 token +- `owner`:用户名或组织名,例如 `hao` +- `repo`:仓库名,例如 `demo-project` +- `source_dir`:要同步的本地目录 +- 可选 `branch`:默认推送的分支名,常见为 `main` +- 可选 `description` / `private` / `tags` + +优先使用环境变量: + +```bash +export GITEA_URL='https://git.hk.hao.work' +export GITEA_TOKEN='' +``` + +## 标准流程 + +1. 先确认本地目录路径、目标 owner 和 repo 名。 +2. 调 Gitea API 查询仓库是否已存在。 +3. 若不存在,则按 owner 类型创建: + - 当前登录用户自己的仓库:`POST /api/v1/user/repos` + - 组织仓库:`POST /api/v1/orgs/{org}/repos` +4. 进入本地目录检查是否为 Git 仓库: + - 不是 Git 仓库时,只有在明确需要时才初始化 + - 已经是 Git 仓库时,优先复用现有提交历史 +5. 配置或校验远程: + - 无 remote 时新增 + - remote 已存在但 URL 不同,默认停止并提示;只有明确允许时才替换 +6. 按请求推送: + - 单分支:`push --set-upstream` + - 全部分支:`push --all` + - 标签:`push --tags` +7. 输出最终仓库 URL、remote URL、本地分支和 push 结果。 + +## 安全默认值 + +- 不强制覆盖现有 remote,除非明确传 `--replace-remote` +- 不自动 `git add` / `git commit`,除非明确传 `--stage-all` 和 `--commit-message` +- 不自动 `force push` +- 不把 token 写进 git remote URL +- 仓库已存在时优先复用,不重复创建 + +## 推荐脚本 + +使用 `scripts/gitea_repo_sync.py`。 + +### 常见用法 + +```bash +python3 scripts/gitea_repo_sync.py \ + --server-url "$GITEA_URL" \ + --token "$GITEA_TOKEN" \ + --owner hao \ + --repo demo-project \ + --source-dir /path/to/project \ + --init-git \ + --branch main \ + --stage-all \ + --commit-message 'Initial import' +``` + +如果仓库已存在,只同步当前分支: + +```bash +python3 scripts/gitea_repo_sync.py \ + --server-url "$GITEA_URL" \ + --token "$GITEA_TOKEN" \ + --owner hao \ + --repo demo-project \ + --source-dir /path/to/project \ + --branch main +``` + +推送全部分支和标签: + +```bash +python3 scripts/gitea_repo_sync.py \ + --server-url "$GITEA_URL" \ + --token "$GITEA_TOKEN" \ + --owner hao \ + --repo demo-project \ + --source-dir /path/to/project \ + --push-all \ + --tags +``` + +## 常见坑 + +- 不要把 `GITEA_TOKEN` 提交进仓库。 +- 不要在 remote URL 里长期保存带 token 的认证串。 +- 不要在 remote 已存在时静默改写 URL。 +- 不要在工作区有未确认修改时自动提交;先确认是否需要 `--stage-all`。 +- 不要默认 `--push-all`;很多场景只需要当前分支。 + +## 参考资料 + +- `references/gitea-api-notes.md` diff --git a/gitea-repo-sync/agents/openai.yaml b/gitea-repo-sync/agents/openai.yaml new file mode 100644 index 0000000..8b1e5e6 --- /dev/null +++ b/gitea-repo-sync/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Gitea Repo Sync" + short_description: "Create Gitea repositories and sync local projects safely" + default_prompt: "Create or reuse a Gitea repository, initialize the current project as git if needed, configure a safe remote, and push the requested branches or tags without storing the token in the remote URL." diff --git a/gitea-repo-sync/references/gitea-api-notes.md b/gitea-repo-sync/references/gitea-api-notes.md new file mode 100644 index 0000000..1aec138 --- /dev/null +++ b/gitea-repo-sync/references/gitea-api-notes.md @@ -0,0 +1,49 @@ +# Gitea API Notes + +本技能默认对接 Gitea v1.25.x,核心只依赖少量稳定接口: + +- `GET /api/v1/user` +- `GET /api/v1/repos/{owner}/{repo}` +- `POST /api/v1/user/repos` +- `POST /api/v1/orgs/{org}/repos` + +## 创建仓库的 owner 选择 + +1. 先调用 `GET /api/v1/user` 获取当前 token 对应登录名。 +2. 如果目标 `owner` 与当前登录名一致,走 `POST /api/v1/user/repos`。 +3. 如果目标 `owner` 不同,按组织仓库处理,走 `POST /api/v1/orgs/{org}/repos`。 + +## 推荐请求头 + +```text +Authorization: token +Content-Type: application/json +Accept: application/json +``` + +## Git Push 认证建议 + +不要把 token 写入 `git remote -v`。 + +优先使用一次性的 HTTP header: + +- 用户名:Gitea 登录名 +- 密码:token +- Header:`Authorization: Basic ` + +这样可以在 push 时认证,但不会把 token 持久化到仓库配置里。 + +## 建议的环境变量 + +```bash +export GITEA_URL='https://git.hk.hao.work' +export GITEA_TOKEN='' +``` + +## 失败时优先检查 + +- token 是否有 repo 创建/写入权限 +- `owner` 是用户还是组织 +- 本地目录是否已经是 Git 仓库 +- 当前分支是否存在提交 +- 远程 URL 是否已指向别的仓库 diff --git a/gitea-repo-sync/scripts/gitea_repo_sync.py b/gitea-repo-sync/scripts/gitea_repo_sync.py new file mode 100755 index 0000000..7b2dd4a --- /dev/null +++ b/gitea-repo-sync/scripts/gitea_repo_sync.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +import argparse +import base64 +import json +import os +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Optional + + +def log(message: str) -> None: + print(f"[gitea-repo-sync] {message}", flush=True) + + +def api_request(server_url: str, token: str, method: str, path: str, payload=None, ok_not_found: bool = False): + url = f"{server_url.rstrip('/')}/api/v1/{path.lstrip('/')}" + data = None + headers = { + "Authorization": f"token {token}", + "Accept": "application/json", + } + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + request = urllib.request.Request(url, data=data, method=method.upper(), headers=headers) + try: + with urllib.request.urlopen(request, timeout=30) as response: + raw = response.read().decode("utf-8") + return response.status, json.loads(raw) if raw else {} + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + if ok_not_found and exc.code == 404: + return 404, None + raise RuntimeError(f"HTTP {exc.code} {method} {url}: {body}") from exc + + +def run_git(args, cwd: Path, auth_header: Optional[str] = None, capture: bool = True) -> str: + cmd = ["git"] + if auth_header: + cmd += ["-c", f"http.extraHeader={auth_header}"] + cmd += args + result = subprocess.run( + cmd, + cwd=str(cwd), + text=True, + capture_output=capture, + check=False, + ) + if result.returncode != 0: + stderr = (result.stderr or "").strip() + stdout = (result.stdout or "").strip() + raise RuntimeError(f"git {' '.join(args)} failed: {stderr or stdout}") + return (result.stdout or "").strip() + + +def is_git_repo(source_dir: Path) -> bool: + result = subprocess.run( + ["git", "rev-parse", "--is-inside-work-tree"], + cwd=str(source_dir), + text=True, + capture_output=True, + check=False, + ) + return result.returncode == 0 and (result.stdout or "").strip() == "true" + + +def ensure_git_repo(source_dir: Path, branch: str, init_git: bool) -> None: + if is_git_repo(source_dir): + return + if not init_git: + raise RuntimeError(f"{source_dir} 不是 Git 仓库;如需初始化请传 --init-git") + log(f"初始化 Git 仓库: {source_dir}") + run_git(["init", "-b", branch], cwd=source_dir) + + +def branch_name_from_head(source_dir: Path) -> str: + result = subprocess.run( + ["git", "symbolic-ref", "--short", "HEAD"], + cwd=str(source_dir), + text=True, + capture_output=True, + check=False, + ) + if result.returncode == 0: + return (result.stdout or "").strip() + return run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=source_dir) + + +def ensure_branch(source_dir: Path, branch: str) -> None: + current = branch_name_from_head(source_dir) + if current == branch: + return + result = subprocess.run( + ["git", "show-ref", "--verify", f"refs/heads/{branch}"], + cwd=str(source_dir), + text=True, + capture_output=True, + check=False, + ) + if result.returncode == 0: + run_git(["checkout", branch], cwd=source_dir) + else: + run_git(["checkout", "-b", branch], cwd=source_dir) + + +def maybe_commit(source_dir: Path, stage_all: bool, commit_message: Optional[str]) -> None: + if not stage_all and not commit_message: + return + if stage_all: + run_git(["add", "-A"], cwd=source_dir) + if commit_message: + status = run_git(["status", "--porcelain"], cwd=source_dir) + if status: + log("创建提交") + run_git(["commit", "-m", commit_message], cwd=source_dir) + + +def get_current_branch(source_dir: Path) -> str: + return branch_name_from_head(source_dir) + + +def get_remote_url(source_dir: Path, remote_name: str) -> Optional[str]: + result = subprocess.run( + ["git", "remote", "get-url", remote_name], + cwd=str(source_dir), + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + return None + return (result.stdout or "").strip() + + +def ensure_remote(source_dir: Path, remote_name: str, remote_url: str, replace_remote: bool) -> None: + current = get_remote_url(source_dir, remote_name) + if not current: + run_git(["remote", "add", remote_name, remote_url], cwd=source_dir) + return + if current == remote_url: + return + if not replace_remote: + raise RuntimeError( + f"remote {remote_name} 已存在且指向 {current};如需替换请显式传 --replace-remote" + ) + run_git(["remote", "set-url", remote_name, remote_url], cwd=source_dir) + + +def basic_auth_header(username: str, token: str) -> str: + raw = f"{username}:{token}".encode("utf-8") + return "Authorization: Basic " + base64.b64encode(raw).decode("ascii") + + +def ensure_repo(server_url: str, token: str, owner: str, repo: str, description: str, private: bool, default_branch: str): + _, user = api_request(server_url, token, "GET", "/user") + username = user["login"] + status, existing = api_request(server_url, token, "GET", f"/repos/{owner}/{repo}", ok_not_found=True) + if status == 200 and existing: + return username, existing, False + + payload = { + "name": repo, + "description": description, + "private": private, + "auto_init": False, + "default_branch": default_branch, + } + if owner == username: + _, created = api_request(server_url, token, "POST", "/user/repos", payload=payload) + else: + _, created = api_request(server_url, token, "POST", f"/orgs/{owner}/repos", payload=payload) + return username, created, True + + +def parse_args(): + parser = argparse.ArgumentParser(description="Create or reuse a Gitea repository and sync a local project.") + parser.add_argument("--server-url", default=os.getenv("GITEA_URL", ""), help="Gitea base URL") + parser.add_argument("--token", default=os.getenv("GITEA_TOKEN", ""), help="Gitea API token") + parser.add_argument("--owner", required=True, help="Repo owner or organization") + parser.add_argument("--repo", required=True, help="Repository name") + parser.add_argument("--source-dir", required=True, help="Local project path") + parser.add_argument("--description", default="", help="Repository description") + parser.add_argument("--branch", default="main", help="Branch to push") + parser.add_argument("--remote-name", default="origin", help="Git remote name") + parser.add_argument("--private", action="store_true", help="Create private repository") + parser.add_argument("--init-git", action="store_true", help="Initialize git when source-dir is not a repository") + parser.add_argument("--replace-remote", action="store_true", help="Replace remote URL when remote exists but differs") + parser.add_argument("--stage-all", action="store_true", help="Run git add -A before commit") + parser.add_argument("--commit-message", default="", help="Create a commit before pushing") + parser.add_argument("--push-all", action="store_true", help="Push all local branches") + parser.add_argument("--tags", action="store_true", help="Push tags after branch push") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if not args.server_url: + raise RuntimeError("缺少 --server-url 或 GITEA_URL") + if not args.token: + raise RuntimeError("缺少 --token 或 GITEA_TOKEN") + + source_dir = Path(args.source_dir).expanduser().resolve() + if not source_dir.exists() or not source_dir.is_dir(): + raise RuntimeError(f"source-dir 不存在或不是目录: {source_dir}") + + username, repo_info, created = ensure_repo( + server_url=args.server_url, + token=args.token, + owner=args.owner, + repo=args.repo, + description=args.description, + private=args.private, + default_branch=args.branch, + ) + remote_url = repo_info.get("clone_url") or repo_info.get("html_url", "").rstrip("/") + ".git" + if not remote_url: + raise RuntimeError("未能从 Gitea 响应中获取 clone_url") + + ensure_git_repo(source_dir, args.branch, args.init_git) + ensure_branch(source_dir, args.branch) + maybe_commit(source_dir, args.stage_all, args.commit_message or None) + ensure_remote(source_dir, args.remote_name, remote_url, args.replace_remote) + + auth_header = basic_auth_header(username, args.token) + branch = get_current_branch(source_dir) + if args.push_all: + log("推送全部分支") + run_git(["push", args.remote_name, "--all"], cwd=source_dir, auth_header=auth_header, capture=True) + else: + log(f"推送分支: {branch}") + run_git(["push", "--set-upstream", args.remote_name, branch], cwd=source_dir, auth_header=auth_header, capture=True) + + if args.tags: + log("推送标签") + run_git(["push", args.remote_name, "--tags"], cwd=source_dir, auth_header=auth_header, capture=True) + + summary = { + "created": created, + "owner": args.owner, + "repo": args.repo, + "branch": branch, + "source_dir": str(source_dir), + "remote_name": args.remote_name, + "remote_url": remote_url, + "html_url": repo_info.get("html_url"), + } + print(json.dumps(summary, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr) + raise SystemExit(1)