feat: add generic gitea issue-drive skills
This commit is contained in:
parent
b36220b33a
commit
22765a2917
174
.agents/skills/issue-drive/SKILL.md
Normal file
174
.agents/skills/issue-drive/SKILL.md
Normal file
@ -0,0 +1,174 @@
|
||||
---
|
||||
name: issue-drive
|
||||
description: 归集证据并把问题拆成 1 到多张 Gitea issue,支持从当前仓库 origin 自动识别仓库或用户显式指定,并通过环境变量配置的 Gitea API 批量创建工单。
|
||||
---
|
||||
|
||||
# Issue Drive - 通用 Gitea 问题拆单与创建
|
||||
|
||||
> **定位**:不是“查看 issue”,而是把一个真实问题沉淀成可执行的 Gitea issue。适合“线上出 bug 了”“要把一个需求拆成工单”“需要补齐工程治理任务”这类场景。
|
||||
>
|
||||
> 如果用户只是想查 issue 列表或详情,用 `issue`。
|
||||
|
||||
当用户调用 `/issue-drive`、`$issue-drive`,或自然语言要求“拆 issue”“提 issue”“把问题沉淀成 Gitea 工单”时,执行以下步骤。
|
||||
|
||||
## 1. 先确定目标仓库
|
||||
|
||||
目标仓库按以下优先级确定:
|
||||
|
||||
1. 用户显式给出的完整仓库 URL:`https://git.example.com/owner/repo`
|
||||
2. 用户显式给出的仓库简写:`owner/repo`,此时需要 `GITEA_BASE_URL`
|
||||
3. 当前项目的 `git remote get-url origin`
|
||||
|
||||
解析规则:
|
||||
|
||||
- 支持 `https://host[/prefix]/owner/repo`
|
||||
- 支持 `git@host:owner/repo.git`
|
||||
- 支持 `git@host:prefix/owner/repo.git`
|
||||
- 支持 `ssh://git@host/owner/repo.git`
|
||||
- 当前仓库 `origin` 是 SSH 地址时:
|
||||
- 优先使用 `GITEA_BASE_URL`
|
||||
- 否则退回 `https://host`
|
||||
|
||||
如果参数和当前仓库都无法确定目标仓库,停止并提示:
|
||||
|
||||
```bash
|
||||
❌ 无法确定目标仓库
|
||||
|
||||
请显式指定仓库,例如:
|
||||
/issue-drive https://git.example.com/owner/repo
|
||||
|
||||
或配置:
|
||||
export GITEA_BASE_URL=https://git.example.com
|
||||
```
|
||||
|
||||
## 2. 检查环境变量
|
||||
|
||||
先读取环境变量:
|
||||
|
||||
- `GITEA_ISSUE`:创建 issue 时优先使用
|
||||
- `GITEA_TOKEN`:`GITEA_ISSUE` 缺失时回退使用
|
||||
- `GITEA_BASE_URL`:可选;仓库简写或 SSH origin 时推荐配置
|
||||
|
||||
如果 `GITEA_ISSUE` 和 `GITEA_TOKEN` 都缺失,输出:
|
||||
|
||||
```bash
|
||||
❌ 缺少 GITEA_ISSUE / GITEA_TOKEN
|
||||
|
||||
请先在当前 shell 或 .env 中配置:
|
||||
export GITEA_ISSUE=your_gitea_write_token
|
||||
export GITEA_TOKEN=your_gitea_token
|
||||
```
|
||||
|
||||
然后停止,不继续创建 issue。
|
||||
|
||||
## 3. 先读仓库基线和证据
|
||||
|
||||
不要先写 issue。先确认事实、范围、影响和证据入口。
|
||||
|
||||
优先读取以下文件;只读存在的那些:
|
||||
|
||||
1. `docs/ISSUE_WORKFLOW.md`
|
||||
2. `.gitea/ISSUE_TEMPLATE/`
|
||||
3. `.github/ISSUE_TEMPLATE/`
|
||||
4. `README.md`
|
||||
5. `doc/`, `docs/` 下与当前问题直接相关的说明、审计、回归、报表文档
|
||||
|
||||
如果仓库里没有现成 issue 基建,就直接基于用户描述、代码、日志、测试结果和已有文档整理事实,不要因为“模板不完整”而停住。
|
||||
|
||||
## 4. 判断该用哪种 issue
|
||||
|
||||
按下面规则分类:
|
||||
|
||||
- **缺陷 / 异常报告**:已有事实证据,目标是排查、修复、回归
|
||||
- **业务需求 / 功能请求**:目标是交付用户价值,需要产品化描述
|
||||
- **工程任务 / 重构 / 基建**:目标是稳定性、可观测性、测试、流程、治理
|
||||
|
||||
标签策略:
|
||||
|
||||
- 优先复用仓库远端已有标签
|
||||
- 如果远端没有对应标签,不要中断;用标题前缀和正文结构表达类型
|
||||
- 不要为了建 issue 先去重构整套标签体系
|
||||
|
||||
## 5. 拆单规则
|
||||
|
||||
不要默认一张大工单。优先按“可独立修复、可独立验证、可独立关闭”拆。
|
||||
|
||||
优先拆开的情况:
|
||||
|
||||
- 现象修复 和 口径 / 文案 / 导出字段修复 是两件事
|
||||
- 业务缺陷 和 工程可观测性补齐 是两件事
|
||||
- 一个问题需要不同 owner、不同验证方式或不同发布时间
|
||||
|
||||
## 6. 写 issue 前的最小检查
|
||||
|
||||
创建 issue 前必须确认:
|
||||
|
||||
- 标题是否直接表达现象或交付物
|
||||
- 正文是否写明:现象、期望、影响范围、证据入口、初步判断、完成标准
|
||||
- 事实数字是否已经从本地数据、日志、代码或用户描述中复核
|
||||
- 引用的仓库内链接是否已经存在于默认分支
|
||||
|
||||
如果本次要新建或更新以下文件:
|
||||
|
||||
- `.gitea/ISSUE_TEMPLATE/*`
|
||||
- `.github/ISSUE_TEMPLATE/*`
|
||||
- `.gitea/PULL_REQUEST_TEMPLATE.md`
|
||||
- `docs/ISSUE_WORKFLOW.md`
|
||||
- issue 正文会引用的证据文档
|
||||
|
||||
先做最小提交并推送到默认分支,再创建 issue。只提交与 issue 基建或证据直接相关的文件,不要顺手带上无关改动。
|
||||
|
||||
## 7. 用脚本批量创建 issue
|
||||
|
||||
优先使用本 skill 附带脚本,并按当前平台选择路径:
|
||||
|
||||
```bash
|
||||
# Codex
|
||||
python3 .codex/skills/issue-drive/scripts/create_gitea_issues.py /tmp/issues.json --dry-run
|
||||
python3 .codex/skills/issue-drive/scripts/create_gitea_issues.py /tmp/issues.json
|
||||
|
||||
# Claude Code
|
||||
python3 .claude/skills/issue-drive/scripts/create_gitea_issues.py /tmp/issues.json --dry-run
|
||||
python3 .claude/skills/issue-drive/scripts/create_gitea_issues.py /tmp/issues.json
|
||||
```
|
||||
|
||||
JSON 结构固定为:
|
||||
|
||||
```json
|
||||
{
|
||||
"repo_url": "https://git.example.com/owner/repo",
|
||||
"issues": [
|
||||
{
|
||||
"title": "[缺陷][登录] 示例标题",
|
||||
"body": "Issue 正文",
|
||||
"labels": ["P1", "bug"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `repo_url` 可省略;脚本会按 `--repo-url` > `spec.repo_url` > `spec.repo` > 当前仓库 `origin` 的顺序解析
|
||||
- `spec.repo` 允许写 `owner/repo`,但需要 `GITEA_BASE_URL`
|
||||
- Token 优先读取 `GITEA_ISSUE`,兼容回退 `GITEA_TOKEN`
|
||||
- `labels` 写标签名,脚本会自动解析成远端 label id
|
||||
- 远端不存在的标签会告警并自动跳过,不中断整个批次
|
||||
- 先跑 `--dry-run`,确认 payload 和目标仓库无误后再正式创建
|
||||
|
||||
## 8. 输出结果
|
||||
|
||||
创建成功后,输出:
|
||||
|
||||
- 已推送的提交信息(如果本次为了 issue 基建或证据先推了提交)
|
||||
- 新建 issue 的编号、标题、URL
|
||||
- 哪些标签成功命中,哪些因远端不存在被跳过
|
||||
- 如果拆成多张 issue,要说明每张工单分别驱动什么工作
|
||||
|
||||
## 9. 行为边界
|
||||
|
||||
- 默认以中文写 issue;用户明确要求英文时再切换
|
||||
- 不把一个大问题硬塞进一张工单
|
||||
- 不在 issue 里写超长散文,优先写清单、事实和完成标准
|
||||
- 不因为缺少完美自动化复现就拒绝提单;现网证据或稳定复现路径成立时,可以先提
|
||||
- 创建 issue 后,不自动继续写修复代码,除非用户明确要求
|
||||
269
.agents/skills/issue-drive/scripts/create_gitea_issues.py
Normal file
269
.agents/skills/issue-drive/scripts/create_gitea_issues.py
Normal file
@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create one or more Gitea issues from a JSON spec."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
SSH_RE = re.compile(r"^git@(?P<host>[^:]+):(?P<path>.+?)(?:\.git)?/?$")
|
||||
REPO_PATH_RE = re.compile(r"^(?P<owner>[^/]+)/(?P<repo>[^/]+)$")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Batch-create Gitea issues from a JSON spec."
|
||||
)
|
||||
parser.add_argument("spec", help="Path to JSON spec file")
|
||||
parser.add_argument(
|
||||
"--repo-url",
|
||||
help=(
|
||||
"Override target repo. Accepts full https URL, SSH git origin, "
|
||||
"or owner/repo with GITEA_BASE_URL."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo",
|
||||
help="Shorthand owner/repo. Requires GITEA_BASE_URL.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Validate and print the payload without creating issues",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_token(required: bool = True) -> str:
|
||||
token = os.getenv("GITEA_ISSUE") or os.getenv("GITEA_TOKEN")
|
||||
if not token and required:
|
||||
raise SystemExit(
|
||||
"Missing GITEA_ISSUE (or fallback GITEA_TOKEN). "
|
||||
"Export the token before creating issues."
|
||||
)
|
||||
return token or ""
|
||||
|
||||
|
||||
def load_spec(path: str) -> dict:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("issues"), list):
|
||||
raise SystemExit("Spec must be an object with an 'issues' array.")
|
||||
if not data["issues"]:
|
||||
raise SystemExit("Spec contains no issues.")
|
||||
return data
|
||||
|
||||
|
||||
def normalize_base_url(base_url: str) -> str:
|
||||
return base_url.rstrip("/")
|
||||
|
||||
|
||||
def parse_http_repo_url(repo_url: str) -> tuple[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}"
|
||||
return origin, owner, repo
|
||||
|
||||
|
||||
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://"):
|
||||
origin, owner, repo = parse_http_repo_url(value)
|
||||
repo_url = f"{origin}/{owner}/{repo}"
|
||||
return origin, owner, repo, repo_url
|
||||
|
||||
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]
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
raise SystemExit("Invalid SSH repo URL. Missing host.")
|
||||
origin = normalize_base_url(base_url or f"https://{host}")
|
||||
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]
|
||||
origin = normalize_base_url(base_url or f"https://{ssh_match.group('host')}")
|
||||
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 --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_origin_repo_target() -> str:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["git", "remote", "get-url", "origin"],
|
||||
text=True,
|
||||
stderr=subprocess.STDOUT,
|
||||
).strip()
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise SystemExit(f"Failed to read git origin: {exc.output.strip()}") from exc
|
||||
|
||||
|
||||
def resolve_repo(spec: dict, args: argparse.Namespace) -> tuple[str, str, str, str]:
|
||||
base_url = os.getenv("GITEA_BASE_URL")
|
||||
repo_target = (
|
||||
args.repo_url
|
||||
or args.repo
|
||||
or spec.get("repo_url")
|
||||
or spec.get("repo")
|
||||
or get_origin_repo_target()
|
||||
)
|
||||
return parse_repo_target(repo_target, base_url=base_url)
|
||||
|
||||
|
||||
def request_json(
|
||||
url: str,
|
||||
token: str,
|
||||
method: str = "GET",
|
||||
payload: dict | None = None,
|
||||
) -> dict | list:
|
||||
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"
|
||||
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req) 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 fetch_label_map(api_base: str, token: str) -> dict[str, int]:
|
||||
data = request_json(f"{api_base}/labels?page=1&limit=100", token)
|
||||
if not isinstance(data, list):
|
||||
raise SystemExit("Unexpected labels API response.")
|
||||
return {
|
||||
item["name"]: item["id"]
|
||||
for item in data
|
||||
if "name" in item and "id" in item
|
||||
}
|
||||
|
||||
|
||||
def resolve_label_ids(label_names: list[str], label_map: dict[str, int]) -> tuple[list[int], list[str]]:
|
||||
ids: list[int] = []
|
||||
missing: list[str] = []
|
||||
for name in label_names:
|
||||
if name in label_map:
|
||||
ids.append(label_map[name])
|
||||
else:
|
||||
missing.append(name)
|
||||
return ids, missing
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
token = load_token(required=not args.dry_run)
|
||||
spec = load_spec(args.spec)
|
||||
origin, owner, repo, repo_url = resolve_repo(spec, args)
|
||||
api_base = f"{origin}/api/v1/repos/{owner}/{repo}"
|
||||
label_map = fetch_label_map(api_base, token) if token else {}
|
||||
|
||||
issues = spec["issues"]
|
||||
for issue in issues:
|
||||
if not isinstance(issue, dict):
|
||||
raise SystemExit("Each issue entry must be an object.")
|
||||
if not issue.get("title") or not issue.get("body"):
|
||||
raise SystemExit("Each issue must include non-empty 'title' and 'body'.")
|
||||
|
||||
for issue in issues:
|
||||
label_names = issue.get("labels") or []
|
||||
if not isinstance(label_names, list) or not all(
|
||||
isinstance(name, str) for name in label_names
|
||||
):
|
||||
raise SystemExit("'labels' must be an array of strings.")
|
||||
|
||||
label_ids, missing_labels = resolve_label_ids(label_names, label_map)
|
||||
payload = {
|
||||
"title": issue["title"],
|
||||
"body": issue["body"],
|
||||
"labels": label_ids,
|
||||
}
|
||||
|
||||
if args.dry_run:
|
||||
print(f"repo_url={repo_url}")
|
||||
print(f"DRY-RUN\t{issue['title']}")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
if missing_labels:
|
||||
print(f"missing_labels={missing_labels}")
|
||||
if not token:
|
||||
print("warning\tno token provided, remote label validation skipped")
|
||||
continue
|
||||
|
||||
created = request_json(f"{api_base}/issues", token, method="POST", payload=payload)
|
||||
if not isinstance(created, dict):
|
||||
raise SystemExit("Unexpected issue creation response.")
|
||||
print(
|
||||
f"#{created.get('number')}\t{created.get('title')}\t{created.get('html_url')}"
|
||||
)
|
||||
if missing_labels:
|
||||
print(f"warning\tmissing labels skipped: {', '.join(missing_labels)}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -1,37 +1,73 @@
|
||||
---
|
||||
name: issue
|
||||
description: 获取任意 Gitea 仓库的 issue 列表和单条详情,支持状态筛选、完整仓库 URL 输入和格式化输出。
|
||||
description: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条详情,支持自动识别 git origin、用户指定仓库、状态筛选和格式化输出。
|
||||
---
|
||||
|
||||
# Issue - 通用 Gitea Issue 查看
|
||||
|
||||
> **定位**:输入完整 Gitea 仓库 URL,快速查看 issue 列表或单条详情。默认只输出事实结果,不主动扩展成 roadmap、主题归纳或优先级分析。
|
||||
> **定位**:这是只读 skill。用于查看当前仓库或指定仓库的 issue 列表、单条详情和评论,不负责拆单和创建工单。
|
||||
>
|
||||
> 如果用户要做的是“把问题拆成 issue 并实际创建工单”,不要使用本 skill,改用 `issue-drive`。
|
||||
|
||||
当用户调用 `issue` skill、`/issue <repo-url>`、`$issue <repo-url>`、`/issue <repo-url> <issue-number>` 或自然语言要求“用 issue skill 查看某个 Gitea 仓库的问题”时,执行以下步骤。
|
||||
当用户调用 `/issue`、`$issue`,或自然语言要求“查看当前仓库 issue”“看某个 Gitea 仓库的 issue”“查 issue #17”时,执行以下步骤。
|
||||
|
||||
## 1. 解析输入
|
||||
|
||||
- `repo-url` 必须是完整仓库地址,例如 `https://git.example.com/owner/repo`
|
||||
- 允许尾部带 `/` 或 `.git`,先规范化后再解析
|
||||
支持以下几种目标仓库写法:
|
||||
|
||||
- 不传仓库参数:默认使用当前项目的 `git remote get-url origin`
|
||||
- 完整仓库 URL:`https://git.example.com/owner/repo`
|
||||
- 仓库简写:`owner/repo`
|
||||
- 当前仓库 + 单条 issue:`/issue 17`
|
||||
- 指定仓库 + 单条 issue:`/issue owner/repo 17` 或 `/issue https://git.example.com/owner/repo 17`
|
||||
|
||||
同时支持:
|
||||
|
||||
- `--state=open|closed|all`,默认 `open`
|
||||
- `--limit=<N>`,默认 `50`
|
||||
|
||||
解析规则:
|
||||
|
||||
- 第二个位置参数如果是纯数字,视为 `issue-number`,进入详情模式
|
||||
- `--state` 仅支持 `open`、`closed`、`all`,默认 `open`
|
||||
- `--limit` 默认 `50`
|
||||
- 不支持只传 issue 编号;如果缺少仓库 URL,先提示正确用法再停止
|
||||
- 如果第一个位置参数是纯数字,则视为“当前仓库的 issue 编号”
|
||||
- `owner/repo` 这种简写依赖 `GITEA_BASE_URL`
|
||||
- 如果没有显式仓库参数,就读取当前仓库的 `origin`
|
||||
|
||||
规范化后,从 `repo-url` 提取:
|
||||
规范化仓库目标时,接受以下输入:
|
||||
|
||||
- `origin`:例如 `https://git.example.com`
|
||||
- `https://host[/prefix]/owner/repo`
|
||||
- `git@host:owner/repo.git`
|
||||
- `git@host:prefix/owner/repo.git`
|
||||
- `ssh://git@host/owner/repo.git`
|
||||
- `owner/repo`
|
||||
|
||||
提取结果必须包含:
|
||||
|
||||
- `origin`:例如 `https://git.example.com`,如果 Gitea 部署在子路径下,保留前缀,例如 `https://git.example.com/gitea`
|
||||
- `owner`
|
||||
- `repo`
|
||||
- `repo_path`:例如 `owner/repo`
|
||||
- `repo_path`:`owner/repo`
|
||||
|
||||
如果 URL 不是标准仓库地址,明确提示“仓库 URL 格式无效,需为 `https://host/owner/repo`”。
|
||||
如果无法从参数或当前仓库推断出目标仓库,明确提示:
|
||||
|
||||
```bash
|
||||
❌ 无法确定目标仓库
|
||||
|
||||
请显式传入:
|
||||
/issue https://git.example.com/owner/repo
|
||||
|
||||
或先配置:
|
||||
export GITEA_BASE_URL=https://git.example.com
|
||||
```
|
||||
|
||||
## 2. 检查环境变量
|
||||
|
||||
先读取环境变量 `GITEA_TOKEN`。
|
||||
先读取环境变量:
|
||||
|
||||
如果缺失,输出:
|
||||
- `GITEA_TOKEN`:必需,读取 issue 时使用
|
||||
- `GITEA_BASE_URL`:可选;当仓库参数是 `owner/repo`,或当前仓库 `origin` 是 SSH 地址时推荐配置
|
||||
|
||||
如果缺少 `GITEA_TOKEN`,输出:
|
||||
|
||||
```bash
|
||||
❌ 缺少 GITEA_TOKEN
|
||||
@ -42,11 +78,29 @@ export GITEA_TOKEN=your_gitea_token
|
||||
|
||||
然后停止,不继续请求 API。
|
||||
|
||||
## 3. 调用 Gitea API
|
||||
## 3. 解析当前仓库 origin
|
||||
|
||||
不要调用仓库元信息接口,避免依赖 `read:repository` scope。仓库标题直接使用 `repo_path`。
|
||||
当没有显式传仓库参数时,执行:
|
||||
|
||||
### 3.1 列表模式
|
||||
```bash
|
||||
git remote get-url origin
|
||||
```
|
||||
|
||||
处理规则:
|
||||
|
||||
- 如果是 `https://host[/prefix]/owner/repo(.git)`,直接使用
|
||||
- 如果是 `git@host:owner/repo(.git)` 或 `ssh://git@host/owner/repo(.git)`:
|
||||
- 优先用 `GITEA_BASE_URL` 作为 API/Web 基地址
|
||||
- 否则退回 `https://host`
|
||||
- 如果当前目录不是 git 仓库,或没有 `origin`,停止并提示用户显式传仓库
|
||||
|
||||
不要为了查 issue 再向用户追问仓库 URL;只有在当前项目和参数都无法推断时才提示。
|
||||
|
||||
## 4. 调用 Gitea API
|
||||
|
||||
不要调用仓库元信息接口,避免依赖额外 scope。仓库标题直接使用 `repo_path`。
|
||||
|
||||
### 4.1 列表模式
|
||||
|
||||
请求:
|
||||
|
||||
@ -56,7 +110,7 @@ curl -sS -o /tmp/gitea_issues.json -w "%{http_code}" \
|
||||
"${origin}/api/v1/repos/${owner}/${repo}/issues?state=${state}&limit=${limit}"
|
||||
```
|
||||
|
||||
### 3.2 详情模式
|
||||
### 4.2 详情模式
|
||||
|
||||
先请求 issue 详情:
|
||||
|
||||
@ -74,22 +128,22 @@ curl -sS -o /tmp/gitea_issue_comments.json -w "%{http_code}" \
|
||||
"${origin}/api/v1/repos/${owner}/${repo}/issues/${issue_number}/comments"
|
||||
```
|
||||
|
||||
### 3.3 错误处理
|
||||
### 4.3 错误处理
|
||||
|
||||
根据 HTTP 状态码给出简短、直接的提示:
|
||||
|
||||
- `401`:`GITEA_TOKEN` 无效或未生效
|
||||
- `403`:token scope 不足,或当前用户无权访问该仓库/issue
|
||||
- `403`:token scope 不足,或当前用户无权访问该仓库 / issue
|
||||
- `404`:仓库不存在,或该 issue 编号不存在
|
||||
- 其他非 `2xx`:输出状态码和响应中的 `message`
|
||||
|
||||
如果列表接口返回项里存在 `pull_request` 且非空,排除这些项,只保留 issue。
|
||||
|
||||
## 4. 格式化输出
|
||||
## 5. 格式化输出
|
||||
|
||||
优先使用 `jq` 解析 JSON;如果环境没有 `jq`,再退回模型手工整理,但输出结构保持一致。
|
||||
|
||||
### 4.1 列表模式
|
||||
### 5.1 列表模式
|
||||
|
||||
输出结构固定为:
|
||||
|
||||
@ -112,7 +166,7 @@ curl -sS -o /tmp/gitea_issue_comments.json -w "%{http_code}" \
|
||||
|
||||
如果过滤后没有任何 issue,明确输出“无符合条件的 issue”。
|
||||
|
||||
### 4.2 详情模式
|
||||
### 5.2 详情模式
|
||||
|
||||
输出结构固定为:
|
||||
|
||||
@ -139,19 +193,30 @@ curl -sS -o /tmp/gitea_issue_comments.json -w "%{http_code}" \
|
||||
|
||||
- 正文为空时写“无正文”
|
||||
- 评论按时间顺序输出
|
||||
- 每条评论只保留 1-2 句摘要;不要整段照抄超长评论
|
||||
- 每条评论只保留 1 到 2 句摘要;不要整段照抄超长评论
|
||||
- 没有评论时明确写“无评论”
|
||||
|
||||
## 5. 行为边界
|
||||
## 6. 行为边界
|
||||
|
||||
- 默认只做列表和单条详情,不主动做主题归纳、epic 合并、优先级建议
|
||||
- 用户后续如果要求摘要、优先级排序、相似 issue 合并,再基于已拉取的数据继续分析
|
||||
- 不要求 `GITEA_BASE_URL`
|
||||
- 不要求用户额外配置固定仓库 URL;优先从当前项目推断
|
||||
- 当前仓库 origin 与 Web 域名不一致时,再使用 `GITEA_BASE_URL`
|
||||
|
||||
## 6. 用法示例
|
||||
## 7. 用法示例
|
||||
|
||||
```bash
|
||||
/issue https://git.internal.intelligrow.cn/intelligrow/feishu_gitea_bridge
|
||||
/issue https://git.internal.intelligrow.cn/intelligrow/feishu_gitea_bridge --state=all --limit=20
|
||||
/issue https://git.internal.intelligrow.cn/intelligrow/feishu_gitea_bridge 17
|
||||
/issue
|
||||
/issue 17
|
||||
/issue owner/repo
|
||||
/issue owner/repo --state=all --limit=20
|
||||
/issue https://git.example.com/owner/repo
|
||||
/issue https://git.example.com/owner/repo 17
|
||||
|
||||
$issue
|
||||
$issue 17
|
||||
$issue owner/repo
|
||||
$issue owner/repo --state=all --limit=20
|
||||
$issue https://git.example.com/owner/repo
|
||||
$issue https://git.example.com/owner/repo 17
|
||||
```
|
||||
|
||||
174
.claude/skills/issue-drive/SKILL.md
Normal file
174
.claude/skills/issue-drive/SKILL.md
Normal file
@ -0,0 +1,174 @@
|
||||
---
|
||||
name: issue-drive
|
||||
description: 归集证据并把问题拆成 1 到多张 Gitea issue,支持从当前仓库 origin 自动识别仓库或用户显式指定,并通过环境变量配置的 Gitea API 批量创建工单。
|
||||
---
|
||||
|
||||
# Issue Drive - 通用 Gitea 问题拆单与创建
|
||||
|
||||
> **定位**:不是“查看 issue”,而是把一个真实问题沉淀成可执行的 Gitea issue。适合“线上出 bug 了”“要把一个需求拆成工单”“需要补齐工程治理任务”这类场景。
|
||||
>
|
||||
> 如果用户只是想查 issue 列表或详情,用 `issue`。
|
||||
|
||||
当用户调用 `/issue-drive`、`$issue-drive`,或自然语言要求“拆 issue”“提 issue”“把问题沉淀成 Gitea 工单”时,执行以下步骤。
|
||||
|
||||
## 1. 先确定目标仓库
|
||||
|
||||
目标仓库按以下优先级确定:
|
||||
|
||||
1. 用户显式给出的完整仓库 URL:`https://git.example.com/owner/repo`
|
||||
2. 用户显式给出的仓库简写:`owner/repo`,此时需要 `GITEA_BASE_URL`
|
||||
3. 当前项目的 `git remote get-url origin`
|
||||
|
||||
解析规则:
|
||||
|
||||
- 支持 `https://host[/prefix]/owner/repo`
|
||||
- 支持 `git@host:owner/repo.git`
|
||||
- 支持 `git@host:prefix/owner/repo.git`
|
||||
- 支持 `ssh://git@host/owner/repo.git`
|
||||
- 当前仓库 `origin` 是 SSH 地址时:
|
||||
- 优先使用 `GITEA_BASE_URL`
|
||||
- 否则退回 `https://host`
|
||||
|
||||
如果参数和当前仓库都无法确定目标仓库,停止并提示:
|
||||
|
||||
```bash
|
||||
❌ 无法确定目标仓库
|
||||
|
||||
请显式指定仓库,例如:
|
||||
/issue-drive https://git.example.com/owner/repo
|
||||
|
||||
或配置:
|
||||
export GITEA_BASE_URL=https://git.example.com
|
||||
```
|
||||
|
||||
## 2. 检查环境变量
|
||||
|
||||
先读取环境变量:
|
||||
|
||||
- `GITEA_ISSUE`:创建 issue 时优先使用
|
||||
- `GITEA_TOKEN`:`GITEA_ISSUE` 缺失时回退使用
|
||||
- `GITEA_BASE_URL`:可选;仓库简写或 SSH origin 时推荐配置
|
||||
|
||||
如果 `GITEA_ISSUE` 和 `GITEA_TOKEN` 都缺失,输出:
|
||||
|
||||
```bash
|
||||
❌ 缺少 GITEA_ISSUE / GITEA_TOKEN
|
||||
|
||||
请先在当前 shell 或 .env 中配置:
|
||||
export GITEA_ISSUE=your_gitea_write_token
|
||||
export GITEA_TOKEN=your_gitea_token
|
||||
```
|
||||
|
||||
然后停止,不继续创建 issue。
|
||||
|
||||
## 3. 先读仓库基线和证据
|
||||
|
||||
不要先写 issue。先确认事实、范围、影响和证据入口。
|
||||
|
||||
优先读取以下文件;只读存在的那些:
|
||||
|
||||
1. `docs/ISSUE_WORKFLOW.md`
|
||||
2. `.gitea/ISSUE_TEMPLATE/`
|
||||
3. `.github/ISSUE_TEMPLATE/`
|
||||
4. `README.md`
|
||||
5. `doc/`, `docs/` 下与当前问题直接相关的说明、审计、回归、报表文档
|
||||
|
||||
如果仓库里没有现成 issue 基建,就直接基于用户描述、代码、日志、测试结果和已有文档整理事实,不要因为“模板不完整”而停住。
|
||||
|
||||
## 4. 判断该用哪种 issue
|
||||
|
||||
按下面规则分类:
|
||||
|
||||
- **缺陷 / 异常报告**:已有事实证据,目标是排查、修复、回归
|
||||
- **业务需求 / 功能请求**:目标是交付用户价值,需要产品化描述
|
||||
- **工程任务 / 重构 / 基建**:目标是稳定性、可观测性、测试、流程、治理
|
||||
|
||||
标签策略:
|
||||
|
||||
- 优先复用仓库远端已有标签
|
||||
- 如果远端没有对应标签,不要中断;用标题前缀和正文结构表达类型
|
||||
- 不要为了建 issue 先去重构整套标签体系
|
||||
|
||||
## 5. 拆单规则
|
||||
|
||||
不要默认一张大工单。优先按“可独立修复、可独立验证、可独立关闭”拆。
|
||||
|
||||
优先拆开的情况:
|
||||
|
||||
- 现象修复 和 口径 / 文案 / 导出字段修复 是两件事
|
||||
- 业务缺陷 和 工程可观测性补齐 是两件事
|
||||
- 一个问题需要不同 owner、不同验证方式或不同发布时间
|
||||
|
||||
## 6. 写 issue 前的最小检查
|
||||
|
||||
创建 issue 前必须确认:
|
||||
|
||||
- 标题是否直接表达现象或交付物
|
||||
- 正文是否写明:现象、期望、影响范围、证据入口、初步判断、完成标准
|
||||
- 事实数字是否已经从本地数据、日志、代码或用户描述中复核
|
||||
- 引用的仓库内链接是否已经存在于默认分支
|
||||
|
||||
如果本次要新建或更新以下文件:
|
||||
|
||||
- `.gitea/ISSUE_TEMPLATE/*`
|
||||
- `.github/ISSUE_TEMPLATE/*`
|
||||
- `.gitea/PULL_REQUEST_TEMPLATE.md`
|
||||
- `docs/ISSUE_WORKFLOW.md`
|
||||
- issue 正文会引用的证据文档
|
||||
|
||||
先做最小提交并推送到默认分支,再创建 issue。只提交与 issue 基建或证据直接相关的文件,不要顺手带上无关改动。
|
||||
|
||||
## 7. 用脚本批量创建 issue
|
||||
|
||||
优先使用本 skill 附带脚本,并按当前平台选择路径:
|
||||
|
||||
```bash
|
||||
# Codex
|
||||
python3 .codex/skills/issue-drive/scripts/create_gitea_issues.py /tmp/issues.json --dry-run
|
||||
python3 .codex/skills/issue-drive/scripts/create_gitea_issues.py /tmp/issues.json
|
||||
|
||||
# Claude Code
|
||||
python3 .claude/skills/issue-drive/scripts/create_gitea_issues.py /tmp/issues.json --dry-run
|
||||
python3 .claude/skills/issue-drive/scripts/create_gitea_issues.py /tmp/issues.json
|
||||
```
|
||||
|
||||
JSON 结构固定为:
|
||||
|
||||
```json
|
||||
{
|
||||
"repo_url": "https://git.example.com/owner/repo",
|
||||
"issues": [
|
||||
{
|
||||
"title": "[缺陷][登录] 示例标题",
|
||||
"body": "Issue 正文",
|
||||
"labels": ["P1", "bug"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `repo_url` 可省略;脚本会按 `--repo-url` > `spec.repo_url` > `spec.repo` > 当前仓库 `origin` 的顺序解析
|
||||
- `spec.repo` 允许写 `owner/repo`,但需要 `GITEA_BASE_URL`
|
||||
- Token 优先读取 `GITEA_ISSUE`,兼容回退 `GITEA_TOKEN`
|
||||
- `labels` 写标签名,脚本会自动解析成远端 label id
|
||||
- 远端不存在的标签会告警并自动跳过,不中断整个批次
|
||||
- 先跑 `--dry-run`,确认 payload 和目标仓库无误后再正式创建
|
||||
|
||||
## 8. 输出结果
|
||||
|
||||
创建成功后,输出:
|
||||
|
||||
- 已推送的提交信息(如果本次为了 issue 基建或证据先推了提交)
|
||||
- 新建 issue 的编号、标题、URL
|
||||
- 哪些标签成功命中,哪些因远端不存在被跳过
|
||||
- 如果拆成多张 issue,要说明每张工单分别驱动什么工作
|
||||
|
||||
## 9. 行为边界
|
||||
|
||||
- 默认以中文写 issue;用户明确要求英文时再切换
|
||||
- 不把一个大问题硬塞进一张工单
|
||||
- 不在 issue 里写超长散文,优先写清单、事实和完成标准
|
||||
- 不因为缺少完美自动化复现就拒绝提单;现网证据或稳定复现路径成立时,可以先提
|
||||
- 创建 issue 后,不自动继续写修复代码,除非用户明确要求
|
||||
269
.claude/skills/issue-drive/scripts/create_gitea_issues.py
Normal file
269
.claude/skills/issue-drive/scripts/create_gitea_issues.py
Normal file
@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create one or more Gitea issues from a JSON spec."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
SSH_RE = re.compile(r"^git@(?P<host>[^:]+):(?P<path>.+?)(?:\.git)?/?$")
|
||||
REPO_PATH_RE = re.compile(r"^(?P<owner>[^/]+)/(?P<repo>[^/]+)$")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Batch-create Gitea issues from a JSON spec."
|
||||
)
|
||||
parser.add_argument("spec", help="Path to JSON spec file")
|
||||
parser.add_argument(
|
||||
"--repo-url",
|
||||
help=(
|
||||
"Override target repo. Accepts full https URL, SSH git origin, "
|
||||
"or owner/repo with GITEA_BASE_URL."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo",
|
||||
help="Shorthand owner/repo. Requires GITEA_BASE_URL.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Validate and print the payload without creating issues",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_token(required: bool = True) -> str:
|
||||
token = os.getenv("GITEA_ISSUE") or os.getenv("GITEA_TOKEN")
|
||||
if not token and required:
|
||||
raise SystemExit(
|
||||
"Missing GITEA_ISSUE (or fallback GITEA_TOKEN). "
|
||||
"Export the token before creating issues."
|
||||
)
|
||||
return token or ""
|
||||
|
||||
|
||||
def load_spec(path: str) -> dict:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("issues"), list):
|
||||
raise SystemExit("Spec must be an object with an 'issues' array.")
|
||||
if not data["issues"]:
|
||||
raise SystemExit("Spec contains no issues.")
|
||||
return data
|
||||
|
||||
|
||||
def normalize_base_url(base_url: str) -> str:
|
||||
return base_url.rstrip("/")
|
||||
|
||||
|
||||
def parse_http_repo_url(repo_url: str) -> tuple[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}"
|
||||
return origin, owner, repo
|
||||
|
||||
|
||||
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://"):
|
||||
origin, owner, repo = parse_http_repo_url(value)
|
||||
repo_url = f"{origin}/{owner}/{repo}"
|
||||
return origin, owner, repo, repo_url
|
||||
|
||||
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]
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
raise SystemExit("Invalid SSH repo URL. Missing host.")
|
||||
origin = normalize_base_url(base_url or f"https://{host}")
|
||||
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]
|
||||
origin = normalize_base_url(base_url or f"https://{ssh_match.group('host')}")
|
||||
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 --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_origin_repo_target() -> str:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["git", "remote", "get-url", "origin"],
|
||||
text=True,
|
||||
stderr=subprocess.STDOUT,
|
||||
).strip()
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise SystemExit(f"Failed to read git origin: {exc.output.strip()}") from exc
|
||||
|
||||
|
||||
def resolve_repo(spec: dict, args: argparse.Namespace) -> tuple[str, str, str, str]:
|
||||
base_url = os.getenv("GITEA_BASE_URL")
|
||||
repo_target = (
|
||||
args.repo_url
|
||||
or args.repo
|
||||
or spec.get("repo_url")
|
||||
or spec.get("repo")
|
||||
or get_origin_repo_target()
|
||||
)
|
||||
return parse_repo_target(repo_target, base_url=base_url)
|
||||
|
||||
|
||||
def request_json(
|
||||
url: str,
|
||||
token: str,
|
||||
method: str = "GET",
|
||||
payload: dict | None = None,
|
||||
) -> dict | list:
|
||||
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"
|
||||
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req) 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 fetch_label_map(api_base: str, token: str) -> dict[str, int]:
|
||||
data = request_json(f"{api_base}/labels?page=1&limit=100", token)
|
||||
if not isinstance(data, list):
|
||||
raise SystemExit("Unexpected labels API response.")
|
||||
return {
|
||||
item["name"]: item["id"]
|
||||
for item in data
|
||||
if "name" in item and "id" in item
|
||||
}
|
||||
|
||||
|
||||
def resolve_label_ids(label_names: list[str], label_map: dict[str, int]) -> tuple[list[int], list[str]]:
|
||||
ids: list[int] = []
|
||||
missing: list[str] = []
|
||||
for name in label_names:
|
||||
if name in label_map:
|
||||
ids.append(label_map[name])
|
||||
else:
|
||||
missing.append(name)
|
||||
return ids, missing
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
token = load_token(required=not args.dry_run)
|
||||
spec = load_spec(args.spec)
|
||||
origin, owner, repo, repo_url = resolve_repo(spec, args)
|
||||
api_base = f"{origin}/api/v1/repos/{owner}/{repo}"
|
||||
label_map = fetch_label_map(api_base, token) if token else {}
|
||||
|
||||
issues = spec["issues"]
|
||||
for issue in issues:
|
||||
if not isinstance(issue, dict):
|
||||
raise SystemExit("Each issue entry must be an object.")
|
||||
if not issue.get("title") or not issue.get("body"):
|
||||
raise SystemExit("Each issue must include non-empty 'title' and 'body'.")
|
||||
|
||||
for issue in issues:
|
||||
label_names = issue.get("labels") or []
|
||||
if not isinstance(label_names, list) or not all(
|
||||
isinstance(name, str) for name in label_names
|
||||
):
|
||||
raise SystemExit("'labels' must be an array of strings.")
|
||||
|
||||
label_ids, missing_labels = resolve_label_ids(label_names, label_map)
|
||||
payload = {
|
||||
"title": issue["title"],
|
||||
"body": issue["body"],
|
||||
"labels": label_ids,
|
||||
}
|
||||
|
||||
if args.dry_run:
|
||||
print(f"repo_url={repo_url}")
|
||||
print(f"DRY-RUN\t{issue['title']}")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
if missing_labels:
|
||||
print(f"missing_labels={missing_labels}")
|
||||
if not token:
|
||||
print("warning\tno token provided, remote label validation skipped")
|
||||
continue
|
||||
|
||||
created = request_json(f"{api_base}/issues", token, method="POST", payload=payload)
|
||||
if not isinstance(created, dict):
|
||||
raise SystemExit("Unexpected issue creation response.")
|
||||
print(
|
||||
f"#{created.get('number')}\t{created.get('title')}\t{created.get('html_url')}"
|
||||
)
|
||||
if missing_labels:
|
||||
print(f"warning\tmissing labels skipped: {', '.join(missing_labels)}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -1,37 +1,73 @@
|
||||
---
|
||||
name: issue
|
||||
description: 获取任意 Gitea 仓库的 issue 列表和单条详情,支持状态筛选、完整仓库 URL 输入和格式化输出。
|
||||
description: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条详情,支持自动识别 git origin、用户指定仓库、状态筛选和格式化输出。
|
||||
---
|
||||
|
||||
# Issue - 通用 Gitea Issue 查看
|
||||
|
||||
> **定位**:输入完整 Gitea 仓库 URL,快速查看 issue 列表或单条详情。默认只输出事实结果,不主动扩展成 roadmap、主题归纳或优先级分析。
|
||||
> **定位**:这是只读 skill。用于查看当前仓库或指定仓库的 issue 列表、单条详情和评论,不负责拆单和创建工单。
|
||||
>
|
||||
> 如果用户要做的是“把问题拆成 issue 并实际创建工单”,不要使用本 skill,改用 `issue-drive`。
|
||||
|
||||
当用户调用 `/issue <repo-url>`、`/issue <repo-url> --state=open|closed|all --limit=<N>`、`/issue <repo-url> <issue-number>`,或自然语言要求“用 issue skill 查看某个 Gitea 仓库的问题”时,执行以下步骤。
|
||||
当用户调用 `/issue`、`$issue`,或自然语言要求“查看当前仓库 issue”“看某个 Gitea 仓库的 issue”“查 issue #17”时,执行以下步骤。
|
||||
|
||||
## 1. 解析输入
|
||||
|
||||
- `repo-url` 必须是完整仓库地址,例如 `https://git.example.com/owner/repo`
|
||||
- 允许尾部带 `/` 或 `.git`,先规范化后再解析
|
||||
支持以下几种目标仓库写法:
|
||||
|
||||
- 不传仓库参数:默认使用当前项目的 `git remote get-url origin`
|
||||
- 完整仓库 URL:`https://git.example.com/owner/repo`
|
||||
- 仓库简写:`owner/repo`
|
||||
- 当前仓库 + 单条 issue:`/issue 17`
|
||||
- 指定仓库 + 单条 issue:`/issue owner/repo 17` 或 `/issue https://git.example.com/owner/repo 17`
|
||||
|
||||
同时支持:
|
||||
|
||||
- `--state=open|closed|all`,默认 `open`
|
||||
- `--limit=<N>`,默认 `50`
|
||||
|
||||
解析规则:
|
||||
|
||||
- 第二个位置参数如果是纯数字,视为 `issue-number`,进入详情模式
|
||||
- `--state` 仅支持 `open`、`closed`、`all`,默认 `open`
|
||||
- `--limit` 默认 `50`
|
||||
- 不支持只传 issue 编号;如果缺少仓库 URL,先提示正确用法再停止
|
||||
- 如果第一个位置参数是纯数字,则视为“当前仓库的 issue 编号”
|
||||
- `owner/repo` 这种简写依赖 `GITEA_BASE_URL`
|
||||
- 如果没有显式仓库参数,就读取当前仓库的 `origin`
|
||||
|
||||
规范化后,从 `repo-url` 提取:
|
||||
规范化仓库目标时,接受以下输入:
|
||||
|
||||
- `origin`:例如 `https://git.example.com`
|
||||
- `https://host[/prefix]/owner/repo`
|
||||
- `git@host:owner/repo.git`
|
||||
- `git@host:prefix/owner/repo.git`
|
||||
- `ssh://git@host/owner/repo.git`
|
||||
- `owner/repo`
|
||||
|
||||
提取结果必须包含:
|
||||
|
||||
- `origin`:例如 `https://git.example.com`,如果 Gitea 部署在子路径下,保留前缀,例如 `https://git.example.com/gitea`
|
||||
- `owner`
|
||||
- `repo`
|
||||
- `repo_path`:例如 `owner/repo`
|
||||
- `repo_path`:`owner/repo`
|
||||
|
||||
如果 URL 不是标准仓库地址,明确提示“仓库 URL 格式无效,需为 `https://host/owner/repo`”。
|
||||
如果无法从参数或当前仓库推断出目标仓库,明确提示:
|
||||
|
||||
```bash
|
||||
❌ 无法确定目标仓库
|
||||
|
||||
请显式传入:
|
||||
/issue https://git.example.com/owner/repo
|
||||
|
||||
或先配置:
|
||||
export GITEA_BASE_URL=https://git.example.com
|
||||
```
|
||||
|
||||
## 2. 检查环境变量
|
||||
|
||||
先读取环境变量 `GITEA_TOKEN`。
|
||||
先读取环境变量:
|
||||
|
||||
如果缺失,输出:
|
||||
- `GITEA_TOKEN`:必需,读取 issue 时使用
|
||||
- `GITEA_BASE_URL`:可选;当仓库参数是 `owner/repo`,或当前仓库 `origin` 是 SSH 地址时推荐配置
|
||||
|
||||
如果缺少 `GITEA_TOKEN`,输出:
|
||||
|
||||
```bash
|
||||
❌ 缺少 GITEA_TOKEN
|
||||
@ -42,11 +78,29 @@ export GITEA_TOKEN=your_gitea_token
|
||||
|
||||
然后停止,不继续请求 API。
|
||||
|
||||
## 3. 调用 Gitea API
|
||||
## 3. 解析当前仓库 origin
|
||||
|
||||
不要调用仓库元信息接口,避免依赖 `read:repository` scope。仓库标题直接使用 `repo_path`。
|
||||
当没有显式传仓库参数时,执行:
|
||||
|
||||
### 3.1 列表模式
|
||||
```bash
|
||||
git remote get-url origin
|
||||
```
|
||||
|
||||
处理规则:
|
||||
|
||||
- 如果是 `https://host[/prefix]/owner/repo(.git)`,直接使用
|
||||
- 如果是 `git@host:owner/repo(.git)` 或 `ssh://git@host/owner/repo(.git)`:
|
||||
- 优先用 `GITEA_BASE_URL` 作为 API/Web 基地址
|
||||
- 否则退回 `https://host`
|
||||
- 如果当前目录不是 git 仓库,或没有 `origin`,停止并提示用户显式传仓库
|
||||
|
||||
不要为了查 issue 再向用户追问仓库 URL;只有在当前项目和参数都无法推断时才提示。
|
||||
|
||||
## 4. 调用 Gitea API
|
||||
|
||||
不要调用仓库元信息接口,避免依赖额外 scope。仓库标题直接使用 `repo_path`。
|
||||
|
||||
### 4.1 列表模式
|
||||
|
||||
请求:
|
||||
|
||||
@ -56,7 +110,7 @@ curl -sS -o /tmp/gitea_issues.json -w "%{http_code}" \
|
||||
"${origin}/api/v1/repos/${owner}/${repo}/issues?state=${state}&limit=${limit}"
|
||||
```
|
||||
|
||||
### 3.2 详情模式
|
||||
### 4.2 详情模式
|
||||
|
||||
先请求 issue 详情:
|
||||
|
||||
@ -74,22 +128,22 @@ curl -sS -o /tmp/gitea_issue_comments.json -w "%{http_code}" \
|
||||
"${origin}/api/v1/repos/${owner}/${repo}/issues/${issue_number}/comments"
|
||||
```
|
||||
|
||||
### 3.3 错误处理
|
||||
### 4.3 错误处理
|
||||
|
||||
根据 HTTP 状态码给出简短、直接的提示:
|
||||
|
||||
- `401`:`GITEA_TOKEN` 无效或未生效
|
||||
- `403`:token scope 不足,或当前用户无权访问该仓库/issue
|
||||
- `403`:token scope 不足,或当前用户无权访问该仓库 / issue
|
||||
- `404`:仓库不存在,或该 issue 编号不存在
|
||||
- 其他非 `2xx`:输出状态码和响应中的 `message`
|
||||
|
||||
如果列表接口返回项里存在 `pull_request` 且非空,排除这些项,只保留 issue。
|
||||
|
||||
## 4. 格式化输出
|
||||
## 5. 格式化输出
|
||||
|
||||
优先使用 `jq` 解析 JSON;如果环境没有 `jq`,再退回模型手工整理,但输出结构保持一致。
|
||||
|
||||
### 4.1 列表模式
|
||||
### 5.1 列表模式
|
||||
|
||||
输出结构固定为:
|
||||
|
||||
@ -112,7 +166,7 @@ curl -sS -o /tmp/gitea_issue_comments.json -w "%{http_code}" \
|
||||
|
||||
如果过滤后没有任何 issue,明确输出“无符合条件的 issue”。
|
||||
|
||||
### 4.2 详情模式
|
||||
### 5.2 详情模式
|
||||
|
||||
输出结构固定为:
|
||||
|
||||
@ -139,19 +193,30 @@ curl -sS -o /tmp/gitea_issue_comments.json -w "%{http_code}" \
|
||||
|
||||
- 正文为空时写“无正文”
|
||||
- 评论按时间顺序输出
|
||||
- 每条评论只保留 1-2 句摘要;不要整段照抄超长评论
|
||||
- 每条评论只保留 1 到 2 句摘要;不要整段照抄超长评论
|
||||
- 没有评论时明确写“无评论”
|
||||
|
||||
## 5. 行为边界
|
||||
## 6. 行为边界
|
||||
|
||||
- 默认只做列表和单条详情,不主动做主题归纳、epic 合并、优先级建议
|
||||
- 用户后续如果要求摘要、优先级排序、相似 issue 合并,再基于已拉取的数据继续分析
|
||||
- 不要求 `GITEA_BASE_URL`
|
||||
- 不要求用户额外配置固定仓库 URL;优先从当前项目推断
|
||||
- 当前仓库 origin 与 Web 域名不一致时,再使用 `GITEA_BASE_URL`
|
||||
|
||||
## 6. 用法示例
|
||||
## 7. 用法示例
|
||||
|
||||
```bash
|
||||
/issue https://git.internal.intelligrow.cn/intelligrow/feishu_gitea_bridge
|
||||
/issue https://git.internal.intelligrow.cn/intelligrow/feishu_gitea_bridge --state=all --limit=20
|
||||
/issue https://git.internal.intelligrow.cn/intelligrow/feishu_gitea_bridge 17
|
||||
/issue
|
||||
/issue 17
|
||||
/issue owner/repo
|
||||
/issue owner/repo --state=all --limit=20
|
||||
/issue https://git.example.com/owner/repo
|
||||
/issue https://git.example.com/owner/repo 17
|
||||
|
||||
$issue
|
||||
$issue 17
|
||||
$issue owner/repo
|
||||
$issue owner/repo --state=all --limit=20
|
||||
$issue https://git.example.com/owner/repo
|
||||
$issue https://git.example.com/owner/repo 17
|
||||
```
|
||||
|
||||
@ -28,7 +28,8 @@
|
||||
- `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`)
|
||||
- `issue`: 获取任意 Gitea 仓库的 issue 列表和单条详情,支持状态筛选、完整仓库 URL 输入和格式化输出。 (file: `./.codex/skills/issue/SKILL.md`)
|
||||
- `issue`: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条详情,支持自动识别 git origin、用户指定仓库和格式化输出。 (file: `./.codex/skills/issue/SKILL.md`)
|
||||
- `issue-drive`: 归集证据并把问题拆成 1 到多张 Gitea issue,支持从当前仓库 origin 自动识别仓库或用户显式指定。 (file: `./.codex/skills/issue-drive/SKILL.md`)
|
||||
- `changelog`: 一键发版:生成更新日志 → commit → 打 tag,全流程自动化。 (file: `./.codex/skills/changelog/SKILL.md`)
|
||||
|
||||
### How to use skills
|
||||
|
||||
174
.codex/skills/issue-drive/SKILL.md
Normal file
174
.codex/skills/issue-drive/SKILL.md
Normal file
@ -0,0 +1,174 @@
|
||||
---
|
||||
name: issue-drive
|
||||
description: 归集证据并把问题拆成 1 到多张 Gitea issue,支持从当前仓库 origin 自动识别仓库或用户显式指定,并通过环境变量配置的 Gitea API 批量创建工单。
|
||||
---
|
||||
|
||||
# Issue Drive - 通用 Gitea 问题拆单与创建
|
||||
|
||||
> **定位**:不是“查看 issue”,而是把一个真实问题沉淀成可执行的 Gitea issue。适合“线上出 bug 了”“要把一个需求拆成工单”“需要补齐工程治理任务”这类场景。
|
||||
>
|
||||
> 如果用户只是想查 issue 列表或详情,用 `issue`。
|
||||
|
||||
当用户调用 `/issue-drive`、`$issue-drive`,或自然语言要求“拆 issue”“提 issue”“把问题沉淀成 Gitea 工单”时,执行以下步骤。
|
||||
|
||||
## 1. 先确定目标仓库
|
||||
|
||||
目标仓库按以下优先级确定:
|
||||
|
||||
1. 用户显式给出的完整仓库 URL:`https://git.example.com/owner/repo`
|
||||
2. 用户显式给出的仓库简写:`owner/repo`,此时需要 `GITEA_BASE_URL`
|
||||
3. 当前项目的 `git remote get-url origin`
|
||||
|
||||
解析规则:
|
||||
|
||||
- 支持 `https://host[/prefix]/owner/repo`
|
||||
- 支持 `git@host:owner/repo.git`
|
||||
- 支持 `git@host:prefix/owner/repo.git`
|
||||
- 支持 `ssh://git@host/owner/repo.git`
|
||||
- 当前仓库 `origin` 是 SSH 地址时:
|
||||
- 优先使用 `GITEA_BASE_URL`
|
||||
- 否则退回 `https://host`
|
||||
|
||||
如果参数和当前仓库都无法确定目标仓库,停止并提示:
|
||||
|
||||
```bash
|
||||
❌ 无法确定目标仓库
|
||||
|
||||
请显式指定仓库,例如:
|
||||
/issue-drive https://git.example.com/owner/repo
|
||||
|
||||
或配置:
|
||||
export GITEA_BASE_URL=https://git.example.com
|
||||
```
|
||||
|
||||
## 2. 检查环境变量
|
||||
|
||||
先读取环境变量:
|
||||
|
||||
- `GITEA_ISSUE`:创建 issue 时优先使用
|
||||
- `GITEA_TOKEN`:`GITEA_ISSUE` 缺失时回退使用
|
||||
- `GITEA_BASE_URL`:可选;仓库简写或 SSH origin 时推荐配置
|
||||
|
||||
如果 `GITEA_ISSUE` 和 `GITEA_TOKEN` 都缺失,输出:
|
||||
|
||||
```bash
|
||||
❌ 缺少 GITEA_ISSUE / GITEA_TOKEN
|
||||
|
||||
请先在当前 shell 或 .env 中配置:
|
||||
export GITEA_ISSUE=your_gitea_write_token
|
||||
export GITEA_TOKEN=your_gitea_token
|
||||
```
|
||||
|
||||
然后停止,不继续创建 issue。
|
||||
|
||||
## 3. 先读仓库基线和证据
|
||||
|
||||
不要先写 issue。先确认事实、范围、影响和证据入口。
|
||||
|
||||
优先读取以下文件;只读存在的那些:
|
||||
|
||||
1. `docs/ISSUE_WORKFLOW.md`
|
||||
2. `.gitea/ISSUE_TEMPLATE/`
|
||||
3. `.github/ISSUE_TEMPLATE/`
|
||||
4. `README.md`
|
||||
5. `doc/`, `docs/` 下与当前问题直接相关的说明、审计、回归、报表文档
|
||||
|
||||
如果仓库里没有现成 issue 基建,就直接基于用户描述、代码、日志、测试结果和已有文档整理事实,不要因为“模板不完整”而停住。
|
||||
|
||||
## 4. 判断该用哪种 issue
|
||||
|
||||
按下面规则分类:
|
||||
|
||||
- **缺陷 / 异常报告**:已有事实证据,目标是排查、修复、回归
|
||||
- **业务需求 / 功能请求**:目标是交付用户价值,需要产品化描述
|
||||
- **工程任务 / 重构 / 基建**:目标是稳定性、可观测性、测试、流程、治理
|
||||
|
||||
标签策略:
|
||||
|
||||
- 优先复用仓库远端已有标签
|
||||
- 如果远端没有对应标签,不要中断;用标题前缀和正文结构表达类型
|
||||
- 不要为了建 issue 先去重构整套标签体系
|
||||
|
||||
## 5. 拆单规则
|
||||
|
||||
不要默认一张大工单。优先按“可独立修复、可独立验证、可独立关闭”拆。
|
||||
|
||||
优先拆开的情况:
|
||||
|
||||
- 现象修复 和 口径 / 文案 / 导出字段修复 是两件事
|
||||
- 业务缺陷 和 工程可观测性补齐 是两件事
|
||||
- 一个问题需要不同 owner、不同验证方式或不同发布时间
|
||||
|
||||
## 6. 写 issue 前的最小检查
|
||||
|
||||
创建 issue 前必须确认:
|
||||
|
||||
- 标题是否直接表达现象或交付物
|
||||
- 正文是否写明:现象、期望、影响范围、证据入口、初步判断、完成标准
|
||||
- 事实数字是否已经从本地数据、日志、代码或用户描述中复核
|
||||
- 引用的仓库内链接是否已经存在于默认分支
|
||||
|
||||
如果本次要新建或更新以下文件:
|
||||
|
||||
- `.gitea/ISSUE_TEMPLATE/*`
|
||||
- `.github/ISSUE_TEMPLATE/*`
|
||||
- `.gitea/PULL_REQUEST_TEMPLATE.md`
|
||||
- `docs/ISSUE_WORKFLOW.md`
|
||||
- issue 正文会引用的证据文档
|
||||
|
||||
先做最小提交并推送到默认分支,再创建 issue。只提交与 issue 基建或证据直接相关的文件,不要顺手带上无关改动。
|
||||
|
||||
## 7. 用脚本批量创建 issue
|
||||
|
||||
优先使用本 skill 附带脚本,并按当前平台选择路径:
|
||||
|
||||
```bash
|
||||
# Codex
|
||||
python3 .codex/skills/issue-drive/scripts/create_gitea_issues.py /tmp/issues.json --dry-run
|
||||
python3 .codex/skills/issue-drive/scripts/create_gitea_issues.py /tmp/issues.json
|
||||
|
||||
# Claude Code
|
||||
python3 .claude/skills/issue-drive/scripts/create_gitea_issues.py /tmp/issues.json --dry-run
|
||||
python3 .claude/skills/issue-drive/scripts/create_gitea_issues.py /tmp/issues.json
|
||||
```
|
||||
|
||||
JSON 结构固定为:
|
||||
|
||||
```json
|
||||
{
|
||||
"repo_url": "https://git.example.com/owner/repo",
|
||||
"issues": [
|
||||
{
|
||||
"title": "[缺陷][登录] 示例标题",
|
||||
"body": "Issue 正文",
|
||||
"labels": ["P1", "bug"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `repo_url` 可省略;脚本会按 `--repo-url` > `spec.repo_url` > `spec.repo` > 当前仓库 `origin` 的顺序解析
|
||||
- `spec.repo` 允许写 `owner/repo`,但需要 `GITEA_BASE_URL`
|
||||
- Token 优先读取 `GITEA_ISSUE`,兼容回退 `GITEA_TOKEN`
|
||||
- `labels` 写标签名,脚本会自动解析成远端 label id
|
||||
- 远端不存在的标签会告警并自动跳过,不中断整个批次
|
||||
- 先跑 `--dry-run`,确认 payload 和目标仓库无误后再正式创建
|
||||
|
||||
## 8. 输出结果
|
||||
|
||||
创建成功后,输出:
|
||||
|
||||
- 已推送的提交信息(如果本次为了 issue 基建或证据先推了提交)
|
||||
- 新建 issue 的编号、标题、URL
|
||||
- 哪些标签成功命中,哪些因远端不存在被跳过
|
||||
- 如果拆成多张 issue,要说明每张工单分别驱动什么工作
|
||||
|
||||
## 9. 行为边界
|
||||
|
||||
- 默认以中文写 issue;用户明确要求英文时再切换
|
||||
- 不把一个大问题硬塞进一张工单
|
||||
- 不在 issue 里写超长散文,优先写清单、事实和完成标准
|
||||
- 不因为缺少完美自动化复现就拒绝提单;现网证据或稳定复现路径成立时,可以先提
|
||||
- 创建 issue 后,不自动继续写修复代码,除非用户明确要求
|
||||
269
.codex/skills/issue-drive/scripts/create_gitea_issues.py
Normal file
269
.codex/skills/issue-drive/scripts/create_gitea_issues.py
Normal file
@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create one or more Gitea issues from a JSON spec."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
SSH_RE = re.compile(r"^git@(?P<host>[^:]+):(?P<path>.+?)(?:\.git)?/?$")
|
||||
REPO_PATH_RE = re.compile(r"^(?P<owner>[^/]+)/(?P<repo>[^/]+)$")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Batch-create Gitea issues from a JSON spec."
|
||||
)
|
||||
parser.add_argument("spec", help="Path to JSON spec file")
|
||||
parser.add_argument(
|
||||
"--repo-url",
|
||||
help=(
|
||||
"Override target repo. Accepts full https URL, SSH git origin, "
|
||||
"or owner/repo with GITEA_BASE_URL."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo",
|
||||
help="Shorthand owner/repo. Requires GITEA_BASE_URL.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Validate and print the payload without creating issues",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_token(required: bool = True) -> str:
|
||||
token = os.getenv("GITEA_ISSUE") or os.getenv("GITEA_TOKEN")
|
||||
if not token and required:
|
||||
raise SystemExit(
|
||||
"Missing GITEA_ISSUE (or fallback GITEA_TOKEN). "
|
||||
"Export the token before creating issues."
|
||||
)
|
||||
return token or ""
|
||||
|
||||
|
||||
def load_spec(path: str) -> dict:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("issues"), list):
|
||||
raise SystemExit("Spec must be an object with an 'issues' array.")
|
||||
if not data["issues"]:
|
||||
raise SystemExit("Spec contains no issues.")
|
||||
return data
|
||||
|
||||
|
||||
def normalize_base_url(base_url: str) -> str:
|
||||
return base_url.rstrip("/")
|
||||
|
||||
|
||||
def parse_http_repo_url(repo_url: str) -> tuple[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}"
|
||||
return origin, owner, repo
|
||||
|
||||
|
||||
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://"):
|
||||
origin, owner, repo = parse_http_repo_url(value)
|
||||
repo_url = f"{origin}/{owner}/{repo}"
|
||||
return origin, owner, repo, repo_url
|
||||
|
||||
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]
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
raise SystemExit("Invalid SSH repo URL. Missing host.")
|
||||
origin = normalize_base_url(base_url or f"https://{host}")
|
||||
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]
|
||||
origin = normalize_base_url(base_url or f"https://{ssh_match.group('host')}")
|
||||
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 --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_origin_repo_target() -> str:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["git", "remote", "get-url", "origin"],
|
||||
text=True,
|
||||
stderr=subprocess.STDOUT,
|
||||
).strip()
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise SystemExit(f"Failed to read git origin: {exc.output.strip()}") from exc
|
||||
|
||||
|
||||
def resolve_repo(spec: dict, args: argparse.Namespace) -> tuple[str, str, str, str]:
|
||||
base_url = os.getenv("GITEA_BASE_URL")
|
||||
repo_target = (
|
||||
args.repo_url
|
||||
or args.repo
|
||||
or spec.get("repo_url")
|
||||
or spec.get("repo")
|
||||
or get_origin_repo_target()
|
||||
)
|
||||
return parse_repo_target(repo_target, base_url=base_url)
|
||||
|
||||
|
||||
def request_json(
|
||||
url: str,
|
||||
token: str,
|
||||
method: str = "GET",
|
||||
payload: dict | None = None,
|
||||
) -> dict | list:
|
||||
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"
|
||||
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req) 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 fetch_label_map(api_base: str, token: str) -> dict[str, int]:
|
||||
data = request_json(f"{api_base}/labels?page=1&limit=100", token)
|
||||
if not isinstance(data, list):
|
||||
raise SystemExit("Unexpected labels API response.")
|
||||
return {
|
||||
item["name"]: item["id"]
|
||||
for item in data
|
||||
if "name" in item and "id" in item
|
||||
}
|
||||
|
||||
|
||||
def resolve_label_ids(label_names: list[str], label_map: dict[str, int]) -> tuple[list[int], list[str]]:
|
||||
ids: list[int] = []
|
||||
missing: list[str] = []
|
||||
for name in label_names:
|
||||
if name in label_map:
|
||||
ids.append(label_map[name])
|
||||
else:
|
||||
missing.append(name)
|
||||
return ids, missing
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
token = load_token(required=not args.dry_run)
|
||||
spec = load_spec(args.spec)
|
||||
origin, owner, repo, repo_url = resolve_repo(spec, args)
|
||||
api_base = f"{origin}/api/v1/repos/{owner}/{repo}"
|
||||
label_map = fetch_label_map(api_base, token) if token else {}
|
||||
|
||||
issues = spec["issues"]
|
||||
for issue in issues:
|
||||
if not isinstance(issue, dict):
|
||||
raise SystemExit("Each issue entry must be an object.")
|
||||
if not issue.get("title") or not issue.get("body"):
|
||||
raise SystemExit("Each issue must include non-empty 'title' and 'body'.")
|
||||
|
||||
for issue in issues:
|
||||
label_names = issue.get("labels") or []
|
||||
if not isinstance(label_names, list) or not all(
|
||||
isinstance(name, str) for name in label_names
|
||||
):
|
||||
raise SystemExit("'labels' must be an array of strings.")
|
||||
|
||||
label_ids, missing_labels = resolve_label_ids(label_names, label_map)
|
||||
payload = {
|
||||
"title": issue["title"],
|
||||
"body": issue["body"],
|
||||
"labels": label_ids,
|
||||
}
|
||||
|
||||
if args.dry_run:
|
||||
print(f"repo_url={repo_url}")
|
||||
print(f"DRY-RUN\t{issue['title']}")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
if missing_labels:
|
||||
print(f"missing_labels={missing_labels}")
|
||||
if not token:
|
||||
print("warning\tno token provided, remote label validation skipped")
|
||||
continue
|
||||
|
||||
created = request_json(f"{api_base}/issues", token, method="POST", payload=payload)
|
||||
if not isinstance(created, dict):
|
||||
raise SystemExit("Unexpected issue creation response.")
|
||||
print(
|
||||
f"#{created.get('number')}\t{created.get('title')}\t{created.get('html_url')}"
|
||||
)
|
||||
if missing_labels:
|
||||
print(f"warning\tmissing labels skipped: {', '.join(missing_labels)}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -1,37 +1,73 @@
|
||||
---
|
||||
name: issue
|
||||
description: 获取任意 Gitea 仓库的 issue 列表和单条详情,支持状态筛选、完整仓库 URL 输入和格式化输出。
|
||||
description: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条详情,支持自动识别 git origin、用户指定仓库、状态筛选和格式化输出。
|
||||
---
|
||||
|
||||
# Issue - 通用 Gitea Issue 查看
|
||||
|
||||
> **定位**:输入完整 Gitea 仓库 URL,快速查看 issue 列表或单条详情。默认只输出事实结果,不主动扩展成 roadmap、主题归纳或优先级分析。
|
||||
> **定位**:这是只读 skill。用于查看当前仓库或指定仓库的 issue 列表、单条详情和评论,不负责拆单和创建工单。
|
||||
>
|
||||
> 如果用户要做的是“把问题拆成 issue 并实际创建工单”,不要使用本 skill,改用 `issue-drive`。
|
||||
|
||||
当用户调用 `/issue <repo-url>`、`/issue <repo-url> --state=open|closed|all --limit=<N>`、`/issue <repo-url> <issue-number>`、`$issue <repo-url>`、`$issue <repo-url> --state=open|closed|all --limit=<N>`、`$issue <repo-url> <issue-number>`,或自然语言要求“用 issue skill 查看某个 Gitea 仓库的问题”时,执行以下步骤。
|
||||
当用户调用 `/issue`、`$issue`,或自然语言要求“查看当前仓库 issue”“看某个 Gitea 仓库的 issue”“查 issue #17”时,执行以下步骤。
|
||||
|
||||
## 1. 解析输入
|
||||
|
||||
- `repo-url` 必须是完整仓库地址,例如 `https://git.example.com/owner/repo`
|
||||
- 允许尾部带 `/` 或 `.git`,先规范化后再解析
|
||||
支持以下几种目标仓库写法:
|
||||
|
||||
- 不传仓库参数:默认使用当前项目的 `git remote get-url origin`
|
||||
- 完整仓库 URL:`https://git.example.com/owner/repo`
|
||||
- 仓库简写:`owner/repo`
|
||||
- 当前仓库 + 单条 issue:`/issue 17`
|
||||
- 指定仓库 + 单条 issue:`/issue owner/repo 17` 或 `/issue https://git.example.com/owner/repo 17`
|
||||
|
||||
同时支持:
|
||||
|
||||
- `--state=open|closed|all`,默认 `open`
|
||||
- `--limit=<N>`,默认 `50`
|
||||
|
||||
解析规则:
|
||||
|
||||
- 第二个位置参数如果是纯数字,视为 `issue-number`,进入详情模式
|
||||
- `--state` 仅支持 `open`、`closed`、`all`,默认 `open`
|
||||
- `--limit` 默认 `50`
|
||||
- 不支持只传 issue 编号;如果缺少仓库 URL,先提示正确用法再停止
|
||||
- 如果第一个位置参数是纯数字,则视为“当前仓库的 issue 编号”
|
||||
- `owner/repo` 这种简写依赖 `GITEA_BASE_URL`
|
||||
- 如果没有显式仓库参数,就读取当前仓库的 `origin`
|
||||
|
||||
规范化后,从 `repo-url` 提取:
|
||||
规范化仓库目标时,接受以下输入:
|
||||
|
||||
- `origin`:例如 `https://git.example.com`
|
||||
- `https://host[/prefix]/owner/repo`
|
||||
- `git@host:owner/repo.git`
|
||||
- `git@host:prefix/owner/repo.git`
|
||||
- `ssh://git@host/owner/repo.git`
|
||||
- `owner/repo`
|
||||
|
||||
提取结果必须包含:
|
||||
|
||||
- `origin`:例如 `https://git.example.com`,如果 Gitea 部署在子路径下,保留前缀,例如 `https://git.example.com/gitea`
|
||||
- `owner`
|
||||
- `repo`
|
||||
- `repo_path`:例如 `owner/repo`
|
||||
- `repo_path`:`owner/repo`
|
||||
|
||||
如果 URL 不是标准仓库地址,明确提示“仓库 URL 格式无效,需为 `https://host/owner/repo`”。
|
||||
如果无法从参数或当前仓库推断出目标仓库,明确提示:
|
||||
|
||||
```bash
|
||||
❌ 无法确定目标仓库
|
||||
|
||||
请显式传入:
|
||||
/issue https://git.example.com/owner/repo
|
||||
|
||||
或先配置:
|
||||
export GITEA_BASE_URL=https://git.example.com
|
||||
```
|
||||
|
||||
## 2. 检查环境变量
|
||||
|
||||
先读取环境变量 `GITEA_TOKEN`。
|
||||
先读取环境变量:
|
||||
|
||||
如果缺失,输出:
|
||||
- `GITEA_TOKEN`:必需,读取 issue 时使用
|
||||
- `GITEA_BASE_URL`:可选;当仓库参数是 `owner/repo`,或当前仓库 `origin` 是 SSH 地址时推荐配置
|
||||
|
||||
如果缺少 `GITEA_TOKEN`,输出:
|
||||
|
||||
```bash
|
||||
❌ 缺少 GITEA_TOKEN
|
||||
@ -42,11 +78,29 @@ export GITEA_TOKEN=your_gitea_token
|
||||
|
||||
然后停止,不继续请求 API。
|
||||
|
||||
## 3. 调用 Gitea API
|
||||
## 3. 解析当前仓库 origin
|
||||
|
||||
不要调用仓库元信息接口,避免依赖 `read:repository` scope。仓库标题直接使用 `repo_path`。
|
||||
当没有显式传仓库参数时,执行:
|
||||
|
||||
### 3.1 列表模式
|
||||
```bash
|
||||
git remote get-url origin
|
||||
```
|
||||
|
||||
处理规则:
|
||||
|
||||
- 如果是 `https://host[/prefix]/owner/repo(.git)`,直接使用
|
||||
- 如果是 `git@host:owner/repo(.git)` 或 `ssh://git@host/owner/repo(.git)`:
|
||||
- 优先用 `GITEA_BASE_URL` 作为 API/Web 基地址
|
||||
- 否则退回 `https://host`
|
||||
- 如果当前目录不是 git 仓库,或没有 `origin`,停止并提示用户显式传仓库
|
||||
|
||||
不要为了查 issue 再向用户追问仓库 URL;只有在当前项目和参数都无法推断时才提示。
|
||||
|
||||
## 4. 调用 Gitea API
|
||||
|
||||
不要调用仓库元信息接口,避免依赖额外 scope。仓库标题直接使用 `repo_path`。
|
||||
|
||||
### 4.1 列表模式
|
||||
|
||||
请求:
|
||||
|
||||
@ -56,7 +110,7 @@ curl -sS -o /tmp/gitea_issues.json -w "%{http_code}" \
|
||||
"${origin}/api/v1/repos/${owner}/${repo}/issues?state=${state}&limit=${limit}"
|
||||
```
|
||||
|
||||
### 3.2 详情模式
|
||||
### 4.2 详情模式
|
||||
|
||||
先请求 issue 详情:
|
||||
|
||||
@ -74,22 +128,22 @@ curl -sS -o /tmp/gitea_issue_comments.json -w "%{http_code}" \
|
||||
"${origin}/api/v1/repos/${owner}/${repo}/issues/${issue_number}/comments"
|
||||
```
|
||||
|
||||
### 3.3 错误处理
|
||||
### 4.3 错误处理
|
||||
|
||||
根据 HTTP 状态码给出简短、直接的提示:
|
||||
|
||||
- `401`:`GITEA_TOKEN` 无效或未生效
|
||||
- `403`:token scope 不足,或当前用户无权访问该仓库/issue
|
||||
- `403`:token scope 不足,或当前用户无权访问该仓库 / issue
|
||||
- `404`:仓库不存在,或该 issue 编号不存在
|
||||
- 其他非 `2xx`:输出状态码和响应中的 `message`
|
||||
|
||||
如果列表接口返回项里存在 `pull_request` 且非空,排除这些项,只保留 issue。
|
||||
|
||||
## 4. 格式化输出
|
||||
## 5. 格式化输出
|
||||
|
||||
优先使用 `jq` 解析 JSON;如果环境没有 `jq`,再退回模型手工整理,但输出结构保持一致。
|
||||
|
||||
### 4.1 列表模式
|
||||
### 5.1 列表模式
|
||||
|
||||
输出结构固定为:
|
||||
|
||||
@ -112,7 +166,7 @@ curl -sS -o /tmp/gitea_issue_comments.json -w "%{http_code}" \
|
||||
|
||||
如果过滤后没有任何 issue,明确输出“无符合条件的 issue”。
|
||||
|
||||
### 4.2 详情模式
|
||||
### 5.2 详情模式
|
||||
|
||||
输出结构固定为:
|
||||
|
||||
@ -139,23 +193,30 @@ curl -sS -o /tmp/gitea_issue_comments.json -w "%{http_code}" \
|
||||
|
||||
- 正文为空时写“无正文”
|
||||
- 评论按时间顺序输出
|
||||
- 每条评论只保留 1-2 句摘要;不要整段照抄超长评论
|
||||
- 每条评论只保留 1 到 2 句摘要;不要整段照抄超长评论
|
||||
- 没有评论时明确写“无评论”
|
||||
|
||||
## 5. 行为边界
|
||||
## 6. 行为边界
|
||||
|
||||
- 默认只做列表和单条详情,不主动做主题归纳、epic 合并、优先级建议
|
||||
- 用户后续如果要求摘要、优先级排序、相似 issue 合并,再基于已拉取的数据继续分析
|
||||
- 不要求 `GITEA_BASE_URL`
|
||||
- 不要求用户额外配置固定仓库 URL;优先从当前项目推断
|
||||
- 当前仓库 origin 与 Web 域名不一致时,再使用 `GITEA_BASE_URL`
|
||||
|
||||
## 6. 用法示例
|
||||
## 7. 用法示例
|
||||
|
||||
```bash
|
||||
/issue https://git.internal.intelligrow.cn/intelligrow/feishu_gitea_bridge
|
||||
/issue https://git.internal.intelligrow.cn/intelligrow/feishu_gitea_bridge --state=all --limit=20
|
||||
/issue https://git.internal.intelligrow.cn/intelligrow/feishu_gitea_bridge 17
|
||||
/issue
|
||||
/issue 17
|
||||
/issue owner/repo
|
||||
/issue owner/repo --state=all --limit=20
|
||||
/issue https://git.example.com/owner/repo
|
||||
/issue https://git.example.com/owner/repo 17
|
||||
|
||||
$issue https://git.internal.intelligrow.cn/intelligrow/feishu_gitea_bridge
|
||||
$issue https://git.internal.intelligrow.cn/intelligrow/feishu_gitea_bridge --state=all --limit=20
|
||||
$issue https://git.internal.intelligrow.cn/intelligrow/feishu_gitea_bridge 17
|
||||
$issue
|
||||
$issue 17
|
||||
$issue owner/repo
|
||||
$issue owner/repo --state=all --limit=20
|
||||
$issue https://git.example.com/owner/repo
|
||||
$issue https://git.example.com/owner/repo 17
|
||||
```
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -11,6 +11,10 @@ write-skills/
|
||||
.agents/skills/*
|
||||
!.agents/skills/issue/
|
||||
!.agents/skills/issue/SKILL.md
|
||||
!.agents/skills/issue-drive/
|
||||
!.agents/skills/issue-drive/SKILL.md
|
||||
!.agents/skills/issue-drive/scripts/
|
||||
!.agents/skills/issue-drive/scripts/create_gitea_issues.py
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
@ -24,7 +24,8 @@
|
||||
- `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`)
|
||||
- `issue`: 获取任意 Gitea 仓库的 issue 列表和单条详情,支持状态筛选、完整仓库 URL 输入和格式化输出。 (file: `./.codex/skills/issue/SKILL.md`)
|
||||
- `issue`: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条详情,支持自动识别 git origin、用户指定仓库和格式化输出。 (file: `./.codex/skills/issue/SKILL.md`)
|
||||
- `issue-drive`: 归集证据并把问题拆成 1 到多张 Gitea issue,支持从当前仓库 origin 自动识别仓库或用户显式指定。 (file: `./.codex/skills/issue-drive/SKILL.md`)
|
||||
- `changelog`: 一键发版:生成更新日志 → commit → 打 tag,全流程自动化。 (file: `./.codex/skills/changelog/SKILL.md`)
|
||||
|
||||
### How to use skills
|
||||
|
||||
@ -28,7 +28,8 @@
|
||||
- `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`)
|
||||
- `issue`: 获取任意 Gitea 仓库的 issue 列表和单条详情,支持状态筛选、完整仓库 URL 输入和格式化输出。 (file: `./.codex/skills/issue/SKILL.md`)
|
||||
- `issue`: 查看当前仓库或任意 Gitea 仓库的 issue 列表和单条详情,支持自动识别 git origin、用户指定仓库和格式化输出。 (file: `./.codex/skills/issue/SKILL.md`)
|
||||
- `issue-drive`: 归集证据并把问题拆成 1 到多张 Gitea issue,支持从当前仓库 origin 自动识别仓库或用户显式指定。 (file: `./.codex/skills/issue-drive/SKILL.md`)
|
||||
- `changelog`: 一键发版:生成更新日志 → commit → 打 tag,全流程自动化。 (file: `./.codex/skills/changelog/SKILL.md`)
|
||||
|
||||
### How to use skills
|
||||
|
||||
51
README.md
51
README.md
@ -47,7 +47,8 @@ RequirementsDoc ──▶ PRD ──▶ FeatureSummary ──▶ DevelopmentPlan
|
||||
| | `doc` | `/doc` | `/doc` | 渐进式文档生成器,先写梗概再迭代完善 |
|
||||
| | `update` | `/up` | `/up` | Skill 升级优化 |
|
||||
| | `deploy` | `/deploy` | `/deploy` | Drone CI/CD 全流程部署引导 |
|
||||
| | `issue` | `/issue` | `/issue` | 通用 Gitea issue 查询(列表 + 单条详情) |
|
||||
| | `issue` | `/issue` | `/issue` | 通用 Gitea issue 查询(支持当前仓库自动识别) |
|
||||
| | `issue-drive` | `/issue-drive` | `/issue-drive` | 通用 Gitea issue 拆单与批量创建 |
|
||||
| | `changelog` | `/changelog` | `/changelog` | 一键发版(日志 + commit + tag) |
|
||||
|
||||
> Codex 兼容历史 `$skill` 写法,但本文档统一以 `/skill` 作为主入口。
|
||||
@ -146,20 +147,26 @@ Codex:
|
||||
请用 wf skill 根据 PRD 生成 FeatureSummary
|
||||
请用 go skill 按 doc/tasks.md 执行未完成任务
|
||||
请用 doc skill 为认证模块写一份 300 字以内的使用说明
|
||||
请用 issue skill 查看 https://git.example.com/owner/repo 的 open issues
|
||||
请用 issue skill 查看当前仓库的 open issues
|
||||
请用 issue-drive skill 把当前 bug 拆成两张 Gitea issue
|
||||
```
|
||||
|
||||
### 查询 Gitea Issues
|
||||
|
||||
先配置:
|
||||
先配置环境变量:
|
||||
|
||||
```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
|
||||
/issue
|
||||
/issue 7
|
||||
/issue owner/repo
|
||||
/issue https://git.example.com/owner/repo
|
||||
/issue https://git.example.com/owner/repo --state=all --limit=20
|
||||
/issue https://git.example.com/owner/repo 7
|
||||
@ -168,11 +175,43 @@ Claude Code:
|
||||
Codex:
|
||||
|
||||
```text
|
||||
/issue
|
||||
/issue 7
|
||||
/issue owner/repo
|
||||
/issue https://git.example.com/owner/repo
|
||||
/issue https://git.example.com/owner/repo --state=all --limit=20
|
||||
/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
|
||||
|
||||
Claude Code:
|
||||
|
||||
```bash
|
||||
/issue-drive
|
||||
```
|
||||
|
||||
Codex:
|
||||
|
||||
```text
|
||||
/issue-drive
|
||||
```
|
||||
|
||||
自然语言也可以:
|
||||
|
||||
```text
|
||||
请用 issue-drive skill 把当前仓库这个线上 bug 拆成两张 Gitea issue
|
||||
请用 issue-drive skill 给 owner/repo 创建一张工程任务 issue,内容是补齐告警和回归测试
|
||||
```
|
||||
|
||||
`issue-drive` 会优先从当前仓库 `origin` 推断目标仓库;如果你显式指定其他仓库,skill 会按用户输入创建 issue。
|
||||
|
||||
### 工作流总览
|
||||
|
||||
```
|
||||
@ -210,13 +249,15 @@ your-project/
|
||||
│ ├── rr/
|
||||
│ ├── rp/
|
||||
│ ├── ...
|
||||
│ └── iter/
|
||||
│ ├── iter/
|
||||
│ └── issue-drive/
|
||||
├── .claude/
|
||||
│ └── skills/ # ← Claude Code Skills 安装位置
|
||||
│ ├── rr/
|
||||
│ ├── rp/
|
||||
│ ├── ...
|
||||
│ └── iter/
|
||||
│ ├── iter/
|
||||
│ └── issue-drive/
|
||||
├── doc/ # ← 文档输出位置
|
||||
│ ├── RequirementsDoc.md
|
||||
│ ├── PRD.md
|
||||
|
||||
20
install.sh
20
install.sh
@ -96,6 +96,7 @@ install_layout() {
|
||||
local skill_dir
|
||||
local skill_name
|
||||
local src_file
|
||||
local rel_path
|
||||
local dst_dir
|
||||
local dst_file
|
||||
local tpl_file
|
||||
@ -116,17 +117,20 @@ install_layout() {
|
||||
for skill_dir in "$TMP_DIR/$SKILLS_SRC"/*/; do
|
||||
[ -d "$skill_dir" ] || continue
|
||||
skill_name=$(basename "$skill_dir")
|
||||
src_file="$skill_dir/SKILL.md"
|
||||
dst_dir="$TARGET/$skill_name"
|
||||
dst_file="$dst_dir/SKILL.md"
|
||||
|
||||
[ -f "$src_file" ] || continue
|
||||
[ -f "$skill_dir/SKILL.md" ] || continue
|
||||
|
||||
sync_file \
|
||||
"$src_file" \
|
||||
"$dst_file" \
|
||||
"✨ 新增: $skill_name ($MODE)" \
|
||||
"🔄 更新: $skill_name ($MODE) (本地版本已备份为 SKILL.md.local.bak)"
|
||||
while IFS= read -r -d '' src_file; do
|
||||
rel_path="${src_file#$skill_dir}"
|
||||
dst_file="$dst_dir/$rel_path"
|
||||
|
||||
sync_file \
|
||||
"$src_file" \
|
||||
"$dst_file" \
|
||||
"✨ 新增: $skill_name/$rel_path ($MODE)" \
|
||||
"🔄 更新: $skill_name/$rel_path ($MODE) (本地版本已备份为 $(basename "$rel_path").local.bak)"
|
||||
done < <(find "$skill_dir" -type f -print0)
|
||||
done
|
||||
|
||||
for tpl_file in "$TMP_DIR/$SKILLS_SRC"/*.template "$TMP_DIR/$SKILLS_SRC"/*.md; do
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user