diff --git a/.agents/skills/issue-drive/SKILL.md b/.agents/skills/issue-drive/SKILL.md new file mode 100644 index 0000000..9a4a401 --- /dev/null +++ b/.agents/skills/issue-drive/SKILL.md @@ -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 后,不自动继续写修复代码,除非用户明确要求 diff --git a/.agents/skills/issue-drive/scripts/create_gitea_issues.py b/.agents/skills/issue-drive/scripts/create_gitea_issues.py new file mode 100644 index 0000000..00fbb2f --- /dev/null +++ b/.agents/skills/issue-drive/scripts/create_gitea_issues.py @@ -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[^:]+):(?P.+?)(?:\.git)?/?$") +REPO_PATH_RE = re.compile(r"^(?P[^/]+)/(?P[^/]+)$") + + +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()) diff --git a/.agents/skills/issue/SKILL.md b/.agents/skills/issue/SKILL.md index 0186f52..781052d 100644 --- a/.agents/skills/issue/SKILL.md +++ b/.agents/skills/issue/SKILL.md @@ -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 `、`$issue `、`/issue ` 或自然语言要求“用 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=`,默认 `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 ``` diff --git a/.claude/skills/issue-drive/SKILL.md b/.claude/skills/issue-drive/SKILL.md new file mode 100644 index 0000000..9a4a401 --- /dev/null +++ b/.claude/skills/issue-drive/SKILL.md @@ -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 后,不自动继续写修复代码,除非用户明确要求 diff --git a/.claude/skills/issue-drive/scripts/create_gitea_issues.py b/.claude/skills/issue-drive/scripts/create_gitea_issues.py new file mode 100644 index 0000000..00fbb2f --- /dev/null +++ b/.claude/skills/issue-drive/scripts/create_gitea_issues.py @@ -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[^:]+):(?P.+?)(?:\.git)?/?$") +REPO_PATH_RE = re.compile(r"^(?P[^/]+)/(?P[^/]+)$") + + +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()) diff --git a/.claude/skills/issue/SKILL.md b/.claude/skills/issue/SKILL.md index 39bb043..781052d 100644 --- a/.claude/skills/issue/SKILL.md +++ b/.claude/skills/issue/SKILL.md @@ -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 `、`/issue --state=open|closed|all --limit=`、`/issue `,或自然语言要求“用 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=`,默认 `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 ``` diff --git a/.codex/skills/AGENTS.md.template b/.codex/skills/AGENTS.md.template index 512dc98..8f3128f 100644 --- a/.codex/skills/AGENTS.md.template +++ b/.codex/skills/AGENTS.md.template @@ -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 diff --git a/.codex/skills/issue-drive/SKILL.md b/.codex/skills/issue-drive/SKILL.md new file mode 100644 index 0000000..9a4a401 --- /dev/null +++ b/.codex/skills/issue-drive/SKILL.md @@ -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 后,不自动继续写修复代码,除非用户明确要求 diff --git a/.codex/skills/issue-drive/scripts/create_gitea_issues.py b/.codex/skills/issue-drive/scripts/create_gitea_issues.py new file mode 100644 index 0000000..00fbb2f --- /dev/null +++ b/.codex/skills/issue-drive/scripts/create_gitea_issues.py @@ -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[^:]+):(?P.+?)(?:\.git)?/?$") +REPO_PATH_RE = re.compile(r"^(?P[^/]+)/(?P[^/]+)$") + + +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()) diff --git a/.codex/skills/issue/SKILL.md b/.codex/skills/issue/SKILL.md index fc493bb..781052d 100644 --- a/.codex/skills/issue/SKILL.md +++ b/.codex/skills/issue/SKILL.md @@ -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 `、`/issue --state=open|closed|all --limit=`、`/issue `、`$issue `、`$issue --state=open|closed|all --limit=`、`$issue `,或自然语言要求“用 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=`,默认 `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 ``` diff --git a/.gitignore b/.gitignore index a0ef7fd..4132ecd 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index e76cc98..6804f11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/AGENTS.md.template b/AGENTS.md.template index 512dc98..8f3128f 100644 --- a/AGENTS.md.template +++ b/AGENTS.md.template @@ -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 diff --git a/README.md b/README.md index 4d033c5..30a722b 100644 --- a/README.md +++ b/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 diff --git a/install.sh b/install.sh index f15cba5..a7502cc 100644 --- a/install.sh +++ b/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