feat: add generic gitea issue-drive skills

This commit is contained in:
zfc 2026-03-12 15:53:26 +08:00
parent b36220b33a
commit 22765a2917
15 changed files with 1681 additions and 109 deletions

View 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 后,不自动继续写修复代码,除非用户明确要求

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

View File

@ -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 句摘要;不要整段照抄超长评论
- 每条评论只保留 12 句摘要;不要整段照抄超长评论
- 没有评论时明确写“无评论”
## 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
```

View 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 后,不自动继续写修复代码,除非用户明确要求

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

View File

@ -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 句摘要;不要整段照抄超长评论
- 每条评论只保留 12 句摘要;不要整段照抄超长评论
- 没有评论时明确写“无评论”
## 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
```

View File

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

View 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 后,不自动继续写修复代码,除非用户明确要求

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

View File

@ -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 句摘要;不要整段照抄超长评论
- 每条评论只保留 12 句摘要;不要整段照抄超长评论
- 没有评论时明确写“无评论”
## 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
View File

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

View File

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

View File

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

View File

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

View File

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