diff --git a/.github/allowed-callers.json b/.github/allowed-callers.json new file mode 100644 index 0000000..6a95821 --- /dev/null +++ b/.github/allowed-callers.json @@ -0,0 +1,11 @@ +{ + "allowed_orgs": [ + "opensourceways" + ], + "allowed_repos": [ + "KadenZhang3321/agent-skills", + "KadenZhang3321/hello-world", + "Meihan-chen/vllm-benchmarks", + "KadenZhang3321/vllm-benchmarks" + ] +} diff --git a/.github/scripts/setup-claude-environment.py b/.github/scripts/setup-claude-environment.py new file mode 100644 index 0000000..cd09f3d --- /dev/null +++ b/.github/scripts/setup-claude-environment.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +""" +setup-claude-environment.py +============================ +为指定仓库创建/验证 GitHub Environment(默认名称 claude-bot), +并配置分支保护规则。 + +用法: + uv run https://raw.githubusercontent.com/opensourceways/agent-skills/main/.github/scripts/setup-claude-environment.py [--force] [--token TOKEN] + +示例: + uv run .github/scripts/setup-claude-environment.py vllm-project/vllm-ascend + uv run .github/scripts/setup-claude-environment.py vllm-project/vllm-ascend --force + uv run .github/scripts/setup-claude-environment.py vllm-project/vllm-ascend --token ghp_xxx + +依赖: + requests(标准场景下通过 pip/uv 安装) + +# /// script +# requires-python = ">=3.9" +# dependencies = ["requests"] +# /// +""" + +from __future__ import annotations + +import argparse +import os +import sys +from typing import Any + +import requests + +# ------------------------------------------------------------------ +# 期望的环境配置(可在此处修改默认值) +# ------------------------------------------------------------------ +DESIRED_CONFIG: dict[str, Any] = { + "environment_name": "claude-bot", + # 受保护分支列表(环境部署规则) + "protected_branches": ["main", "refs/pull/*/merge"], + # 等待计时器(分钟),0 表示不等待 + "wait_timer": 0, + # 是否要求审阅者批准 + "reviewers": [], + # 是否限制部署分支 + "deployment_branch_policy": { + "protected_branches": True, + "custom_branch_policies": False, + }, +} + +GITHUB_API = "https://api.github.com" + + +def get_token(token_arg: str | None) -> str: + """从参数或环境变量获取 GitHub Token。""" + token = token_arg or os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + if not token: + print( + "ERROR: GitHub token not found.\n" + "Provide via --token, GH_TOKEN, or GITHUB_TOKEN environment variable.", + file=sys.stderr, + ) + sys.exit(1) + return token + + +def github_request( + method: str, + path: str, + token: str, + json: Any = None, + expected_statuses: tuple[int, ...] = (200, 201, 204), +) -> tuple[int, Any]: + """执行 GitHub API 请求,返回 (status_code, response_body)。""" + url = f"{GITHUB_API}{path}" + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + resp = requests.request(method, url, headers=headers, json=json, timeout=30) + if resp.status_code not in expected_statuses: + print( + f" [WARN] {method} {url} -> HTTP {resp.status_code}: {resp.text[:200]}", + file=sys.stderr, + ) + body = None + if resp.content: + try: + body = resp.json() + except Exception: + body = resp.text + return resp.status_code, body + + +def check_admin_permission(repo: str, token: str) -> None: + """检查当前 token 对应用户是否拥有仓库 admin 权限。""" + # 获取当前用户 + status, user = github_request("GET", "/user", token) + if status != 200 or not isinstance(user, dict): + print("ERROR: Failed to retrieve authenticated user.", file=sys.stderr) + sys.exit(1) + username = user["login"] + + # 检查权限 + status, perm = github_request( + "GET", + f"/repos/{repo}/collaborators/{username}/permission", + token, + expected_statuses=(200, 403, 404), + ) + if status != 200 or not isinstance(perm, dict): + print( + f"ERROR: Cannot check permission for {username} on {repo} (HTTP {status}).\n" + "Ensure your token has 'repo' scope and you are a collaborator.", + file=sys.stderr, + ) + sys.exit(1) + + permission = perm.get("permission", "none") + if permission != "admin": + print( + f"ERROR: You ({username}) have '{permission}' permission on {repo}.\n" + "Administrator permission is required to manage environments.", + file=sys.stderr, + ) + sys.exit(1) + + print(f" ✓ Authenticated as {username} with admin permission on {repo}.") + + +def get_environment(repo: str, env_name: str, token: str) -> dict | None: + """获取已有 Environment 配置,不存在则返回 None。""" + status, body = github_request( + "GET", + f"/repos/{repo}/environments/{env_name}", + token, + expected_statuses=(200, 404), + ) + if status == 200 and isinstance(body, dict): + return body + return None + + +def create_or_update_environment( + repo: str, + env_name: str, + token: str, + config: dict[str, Any], +) -> dict: + """ + 创建或更新 GitHub Environment。 + API 文档:https://docs.github.com/en/rest/deployments/environments + """ + payload: dict[str, Any] = { + "wait_timer": config.get("wait_timer", 0), + "reviewers": config.get("reviewers", []), + "deployment_branch_policy": config.get("deployment_branch_policy"), + } + + status, body = github_request( + "PUT", + f"/repos/{repo}/environments/{env_name}", + token, + json=payload, + expected_statuses=(200, 201), + ) + if status not in (200, 201) or not isinstance(body, dict): + print( + f"ERROR: Failed to create/update environment '{env_name}' (HTTP {status}).", + file=sys.stderr, + ) + sys.exit(1) + return body + + +def diff_config(existing: dict, desired: dict[str, Any]) -> list[str]: + """对比现有配置与期望配置,返回差异描述列表。""" + diffs: list[str] = [] + + # 等待计时器 + existing_wait = existing.get("wait_timer", 0) + desired_wait = desired.get("wait_timer", 0) + if existing_wait != desired_wait: + diffs.append(f" wait_timer: {existing_wait} -> {desired_wait}") + + # 部署分支策略 + existing_policy = existing.get("deployment_branch_policy") or {} + desired_policy = desired.get("deployment_branch_policy") or {} + for key in ("protected_branches", "custom_branch_policies"): + ev = existing_policy.get(key) + dv = desired_policy.get(key) + if ev != dv: + diffs.append(f" deployment_branch_policy.{key}: {ev} -> {dv}") + + return diffs + + +def print_environment_summary(env: dict) -> None: + """打印 Environment 摘要。""" + name = env.get("name", "?") + url = env.get("html_url", "") + policy = env.get("deployment_branch_policy") or {} + print(f" Name : {name}") + print(f" URL : {url}") + print(f" Protected branches: {policy.get('protected_branches', False)}") + print(f" Custom policies : {policy.get('custom_branch_policies', False)}") + print(f" Wait timer (min) : {env.get('wait_timer', 0)}") + + +def main() -> None: + parser = argparse.ArgumentParser( + description=( + "Create or verify GitHub Environment 'claude-bot' for a repository.\n\n" + "This script is designed to be used with the vllm-project Claude Bot integration." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "repository", + help="Target repository in 'owner/repo' format (e.g. vllm-project/vllm-ascend)", + ) + parser.add_argument( + "--token", + default=None, + help="GitHub Personal Access Token (overrides GH_TOKEN / GITHUB_TOKEN env var)", + ) + parser.add_argument( + "--env-name", + default=DESIRED_CONFIG["environment_name"], + help=f"Environment name to create/verify (default: {DESIRED_CONFIG['environment_name']})", + ) + parser.add_argument( + "--force", + action="store_true", + help="Force overwrite existing environment configuration with desired state", + ) + args = parser.parse_args() + + repo = args.repository + env_name = args.env_name + token = get_token(args.token) + + # 覆盖期望配置中的环境名称 + desired = dict(DESIRED_CONFIG) + desired["environment_name"] = env_name + + print(f"\n{'=' * 60}") + print(f" Claude Bot Environment Setup") + print(f" Repository : {repo}") + print(f" Environment: {env_name}") + print(f"{'=' * 60}\n") + + # 1. 检查管理员权限 + print("[1/4] Checking admin permission...") + check_admin_permission(repo, token) + + # 2. 检查 Environment 是否已存在 + print(f"\n[2/4] Checking environment '{env_name}'...") + existing = get_environment(repo, env_name, token) + + if existing: + print(f" Environment '{env_name}' already exists:") + print_environment_summary(existing) + + # 3. 对比差异 + print(f"\n[3/4] Comparing with desired configuration...") + diffs = diff_config(existing, desired) + + if not diffs: + print( + " ✓ Environment configuration matches desired state. No changes needed." + ) + if not args.force: + print("\nDone.") + return + else: + print(" Differences found:") + for d in diffs: + print(d) + if not args.force: + print( + "\n To apply these changes, re-run with --force flag:\n" + f" uv run .github/scripts/setup-claude-environment.py {repo} --force" + ) + sys.exit(0) + else: + print(f" Environment '{env_name}' does not exist. It will be created.") + print("\n[3/4] Skipping diff (environment not found).") + + # 4. 创建或强制更新 + action = "Updating" if existing else "Creating" + print(f"\n[4/4] {action} environment '{env_name}'...") + result = create_or_update_environment(repo, env_name, token, desired) + print(f" ✓ Environment '{env_name}' has been {action.lower()}d:") + print_environment_summary(result) + + print(f"\n{'=' * 60}") + print(" Setup complete!") + print(f" View at: https://github.com/{repo}/settings/environments") + print(f"{'=' * 60}\n") + print( + "Next steps:\n" + f" 1. Copy .github/workflows/example-caller.yml to {repo}/.github/workflows/\n" + " 2. Set required secrets (CLAUDE_API_KEY, USAGE_PAT)\n" + " 3. Commit and push to trigger on the next @claude mention\n" + ) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..436ec86 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,199 @@ +# Claude Code 工作流集成指南 + +在任意仓库中集成 Claude Code,通过 `@claude` 触发代码审查,或在 CI 中自动分析 PR。 +API Key 集中存储在 `KadenZhang3321/agent-skills`,调用方无需配置。 + +## 前置条件(每个调用方仓库做一次) + +1. 向 agent-skills 管理员申请一个 PAT(`repo` scope) +2. 在调用方仓库添加 Secret: + **Settings → Secrets and variables → Actions → New repository secret** + - Name: `DISPATCH_TOKEN` + - Value: 申请到的 PAT + +--- + +## 快速开始 + +复制 [TEMPLATE-CALLER.yml](./TEMPLATE-CALLER.yml) 到目标仓库: + +```bash +mkdir -p .github/workflows +curl -sSL https://raw.githubusercontent.com/KadenZhang3321/agent-skills/main/.github/workflows/TEMPLATE-CALLER.yml \ + -o .github/workflows/claude-bot.yml +git add .github/workflows/claude-bot.yml +git commit -m "ci: add Claude Bot workflow" +git push +``` + +`TEMPLATE-CALLER.yml` 已内置三种触发方式,按需保留即可: +- **手动触发**(`workflow_dispatch`) +- **PR 评论触发**(`issue_comment`,需包含 `@claude`) +- **PR 自动触发**(`pull_request`,opened/synchronize) + +--- + +## 参数说明 + +在 caller 文件的 `with` 中配置以下参数: + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `allow_code_change` | `false` | `true` 允许 Claude 修改文件并生成 patch artifact;`false` 只分析 | +| `skill_name` | `''` | 指定 Skill 名称(见下方说明),留空不使用 | +| `skill_url` | `''` | 远程 Skill 文件的 raw URL,优先级高于 skill_name | +| `model` | `'claude-sonnet-4-20250514'` | Claude 模型名称 | +| `caller_image` | `''` | 调用方提供的镜像,留空使用 `ubuntu:24.04` | +| `verify_command` | `''` | 验证命令,验证失败则 job 失败 | + +### caller_image 示例 + +```yaml +# 调用方提供的镜像,镜像需要预装 Node.js、Git、依赖已安装 +'caller_image': 'ghcr.io/caller/project:main', + +# 使用 ubuntu:24.04(默认) +'caller_image': '', +``` + +### verify_command 示例 + +```yaml +# 验证命令,验证失败则 job 失败 +'verify_command': 'npm run build && npm test', + +# 不验证(默认) +'verify_command': '', +``` + +### allow_code_change 示例 + +```yaml +# 只审查,不改代码(推荐用于自动触发) +'allow_code_change': false, + +# 允许改代码,生成 patch artifact +'allow_code_change': true, +``` + +### skill_name 示例 + +Skill 按以下顺序查找: + +1. **远程 URL**:如果指定了 `skill_url`,从 URL 下载(优先级最高) +2. **调用方仓库(.github)**:`.github/skills//SKILL.md` +3. **调用方仓库(.ai)**:`.ai/skills//SKILL.md` +4. **agent-skills**:`skills//SKILL.md` + +```yaml +# 使用 agent-skills 内置 Skill +'skill_name': 'infrastructure/github-action-diagnose', +'skill_name': 'infrastructure/docker-image-pr-fix', + +# 使用调用方仓库自定义 Skill(会按上述顺序查找) +'skill_name': 'my-skill', + +# 不使用 Skill +'skill_name': '', +``` + +agent-skills 内置 Skill 列表见 [skills/](../../skills/)。 + +### skill_url 示例 + +使用远程仓库的 Skill 文件(优先于 skill_name): + +```yaml +# 使用远程 Skill(注意:URL 必须是 raw 内容链接) +'skill_url': 'https://raw.githubusercontent.com/owner/repo/main/.ai/skills/my-skill/SKILL.md', +``` + +> 提示:GitHub raw URL 格式为 `https://raw.githubusercontent.com////` + +### model 示例 + +```yaml +# 默认(速度与能力均衡,推荐) +'model': 'claude-sonnet-4-20250514', + +# 最强模型,适合复杂分析 +'model': 'claude-opus-4-20251114', + +# 最快,适合轻量任务 +'model': 'claude-haiku-4-5-20251001', +``` + +--- + +## 白名单管理 + +允许访问的组织和仓库在 [../.github/allowed-callers.json](../allowed-callers.json) 中配置: + +```json +{ + "allowed_orgs": [ + "opensourceways" + ], + "allowed_repos": [ + "KadenZhang3321/agent-skills", + "KadenZhang3321/hello-world" + ] +} +``` + +- `allowed_orgs`:整个组织下所有仓库都可以使用 +- `allowed_repos`:单独授权某个仓库(不在上述组织内也可以) + +新增仓库或组织,修改此文件并合并到 main 即可,无需其他改动。 + +--- + +## 工作原理 + +``` +调用方仓库 agent-skills +───────────────── ──────────────────────────────── +PR 评论 / PR 事件 / 手动触发 + └─ TEMPLATE-CALLER.yml + └─ workflow_call ───────────> _claude-code.yml + 1. 安装基础依赖(含 python3、gh) + 2. Checkout agent-skills + 3. 白名单校验 + 4. 用户权限校验 + 5. 安装 Claude Code + 6. Checkout 调用方仓库 + 7. 拉取 PR Diff + 8. 构建 Prompt(含 Skill) + 9. 调用 Claude(使用集中的 API Key) + 10. verify_command 不为空? + └─ 执行验证命令 + 11. 生成 patch artifact(如有变更) + 12. 在原 PR/Issue 发布评论 +``` + +--- + +## agent-skills 需配置的 Secrets + +| Secret | 用途 | +|--------|------| +| `CLAUDE_API_KEY` | Anthropic API Key,用于调用 Claude | +| `DISPATCH_TOKEN` | PAT(`repo` scope),用于读写调用方仓库、发评论、创建 PR | + +--- + +## 故障排查 + +**agent-skills Actions 没有触发** +- 检查 `DISPATCH_TOKEN` 是否配置了 `repo` scope(`public_repo` 不够) +- 确认 agent-skills 的 Actions 已开启 + +**白名单拒绝** +- 在 `allowed-callers.json` 中添加对应的 org 或 repo + +**Claude 没有发评论** +- 检查 `DISPATCH_TOKEN` 是否有目标仓库的写权限 +- 查看 agent-skills Actions 中该次 run 的 `Post comment` 步骤日志 + +**Token/Cost 显示 N/A** +- 检查 `CLAUDE_API_KEY` 是否正确配置 diff --git a/.github/workflows/TEMPLATE-CALLER.yml b/.github/workflows/TEMPLATE-CALLER.yml new file mode 100644 index 0000000..f37285c --- /dev/null +++ b/.github/workflows/TEMPLATE-CALLER.yml @@ -0,0 +1,281 @@ +# TEMPLATE-CALLER.yml +# 通用调用模板 - 展示如何在业务仓库中调用中心 Claude Code 服务 +# +# 使用方法: +# 1. 复制此文件到你的业务仓库的 .github/workflows/ 目录 +# 2. 根据需要修改触发条件和参数 +# 3. 确保在仓库 Settings → Secrets 中配置了 DISPATCH_TOKEN 和 CLAUDE_API_KEY + +name: Claude Code Caller Template + +on: + # 方式 1:手动触发(测试用) + workflow_dispatch: + inputs: + prompt: + description: '要执行的 prompt' + required: true + default: '请分析这个仓库的代码结构' + allow_code_change: + description: '是否允许代码修改' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' + + # 方式 2:PR 评论触发 + issue_comment: + types: [created] + + # 方式 3:PR 打开或更新时自动触发 + pull_request: + types: [opened, synchronize] + +jobs: + # -------------------------------------------------------------------------- + # 方式 A:手动触发 + # -------------------------------------------------------------------------- + claude-manual: + if: github.event_name == 'workflow_dispatch' + uses: KadenZhang3321/agent-skills/.github/workflows/_claude-code.yml@main + with: + caller_repo: ${{ github.repository }} + custom_prompt: ${{ github.event.inputs.prompt }} + allow_code_change: ${{ github.event.inputs.allow_code_change == 'true' }} + model: 'claude-sonnet-4-20250514' + show_full_output: true + allowed_tools: 'Bash(git *),Bash(gh *),Read,Write,Edit,Glob,Grep' + secrets: + DISPATCH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} + CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} + + claude-manual-result: + if: github.event_name == 'workflow_dispatch' + needs: claude-manual + runs-on: ubuntu-latest + steps: + - name: Check Claude result + run: | + echo "Changes detected: ${{ needs.claude-manual.outputs.changes_detected }}" + echo "Cost: ${{ needs.claude-manual.outputs.cost_usd }} USD" + echo "Input tokens: ${{ needs.claude-manual.outputs.input_tokens }}" + echo "Output tokens: ${{ needs.claude-manual.outputs.output_tokens }}" + echo "Response summary: ${{ needs.claude-manual.outputs.response_summary }}" + + if [[ "${{ needs.claude-manual.outputs.error_message }}" != "" ]]; then + echo "Error: ${{ needs.claude-manual.outputs.error_message }}" + exit 1 + fi + + # 【可选】自动应用 Claude 的代码修改 + # 仅当 changes_detected == 'true' 时才会执行 + claude-manual-apply: + if: ${{ needs.claude-manual.outputs.changes_detected == 'true' && github.event_name == 'workflow_dispatch' }} + needs: claude-manual + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.DISPATCH_TOKEN }} + fetch-depth: 0 + + - name: Download patch artifact + uses: actions/download-artifact@v4 + with: + name: claude-changes-patch + path: /tmp/patch + + - name: Apply patch and create draft PR + env: + GH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} + run: | + git config user.name "Claude Bot" + git config user.email "claude-bot@users.noreply.github.com" + if [ ! -s /tmp/patch/claude-changes.patch ]; then + echo "::warning::Patch file is empty, nothing to apply" + exit 0 + fi + + BASE_BRANCH=$(git branch --show-current) + NEW_BRANCH="claude-patch-${{ github.run_id }}" + + git checkout -b "$NEW_BRANCH" + git apply /tmp/patch/claude-changes.patch + git add -A + git commit -m "AI: apply Claude suggestions" + git push origin "$NEW_BRANCH" + + gh pr create \ + --base "$BASE_BRANCH" \ + --head "$NEW_BRANCH" \ + --title "[Claude] Auto patch from run ${{ github.run_id }}" \ + --body "This PR was generated by applying the Claude patch artifact. Please review before merging." \ + --draft + + echo "::notice::Created draft PR from branch $NEW_BRANCH to $BASE_BRANCH" + + # -------------------------------------------------------------------------- + # 方式 B:PR 评论触发(仅响应包含 @claude 的 PR 评论) + # -------------------------------------------------------------------------- + claude-comment: + if: | + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + contains(github.event.comment.body, '@claude') && + !endsWith(github.actor, '[bot]') && + github.actor != 'dependabot' + uses: KadenZhang3321/agent-skills/.github/workflows/_claude-code.yml@main + with: + caller_repo: ${{ github.repository }} + pr_number: ${{ github.event.issue.number }} + issue_number: ${{ github.event.issue.number }} + comment_author: ${{ github.event.comment.user.login }} + comment_body: ${{ github.event.comment.body }} + allow_code_change: false + model: 'claude-sonnet-4-20250514' + secrets: + DISPATCH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} + CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} + + # 【可选】自动应用 Claude 的代码修改 + # 仅当 changes_detected == 'true' 时才会执行 + claude-comment-apply: + if: ${{ needs.claude-comment.outputs.changes_detected == 'true' && github.event_name == 'issue_comment' }} + needs: claude-comment + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Get PR info + id: pr + env: + GH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} + PR_NUMBER: ${{ github.event.issue.number }} + REPO: ${{ github.repository }} + run: | + HEAD=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefName -q .headRefName) + BASE=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json baseRefName -q .baseRefName) + echo "head=$HEAD" >> "$GITHUB_OUTPUT" + echo "base=$BASE" >> "$GITHUB_OUTPUT" + + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ steps.pr.outputs.head }} + token: ${{ secrets.DISPATCH_TOKEN }} + fetch-depth: 0 + + - name: Download patch artifact + uses: actions/download-artifact@v4 + with: + name: claude-changes-patch + path: /tmp/patch + + - name: Apply patch and create draft PR + env: + GH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} + run: | + git config user.name "Claude Bot" + git config user.email "claude-bot@users.noreply.github.com" + if [ ! -s /tmp/patch/claude-changes.patch ]; then + echo "::warning::Patch file is empty, nothing to apply" + exit 0 + fi + + BASE_BRANCH="${{ steps.pr.outputs.base }}" + NEW_BRANCH="claude-patch-${{ github.run_id }}" + + git checkout -b "$NEW_BRANCH" + git apply /tmp/patch/claude-changes.patch + git add -A + git commit -m "AI: apply Claude suggestions" + git push origin "$NEW_BRANCH" + + gh pr create \ + --base "$BASE_BRANCH" \ + --head "$NEW_BRANCH" \ + --title "[Claude] Auto patch from run ${{ github.run_id }}" \ + --body "This PR was generated by applying the Claude patch artifact. Please review before merging." \ + --draft + + echo "::notice::Created draft PR from branch $NEW_BRANCH to $BASE_BRANCH" + + # -------------------------------------------------------------------------- + # 方式 C:PR 自动触发 + # -------------------------------------------------------------------------- + claude-auto-pr: + if: | + github.event_name == 'pull_request' && + github.event.pull_request.draft == false + uses: KadenZhang3321/agent-skills/.github/workflows/_claude-code.yml@main + with: + caller_repo: ${{ github.repository }} + pr_number: ${{ github.event.pull_request.number }} + issue_number: ${{ github.event.pull_request.number }} + comment_author: ${{ github.event.pull_request.user.login }} + comment_body: 'Auto-triggered: Please review this PR and suggest improvements.' + allow_code_change: false + skill_name: 'github-action-diagnose' + model: 'claude-sonnet-4-20250514' + secrets: + DISPATCH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} + CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} + + # 【可选】自动应用 Claude 的代码修改 + # 仅当 changes_detected == 'true' 时才会执行 + claude-auto-pr-apply: + if: ${{ needs.claude-auto-pr.outputs.changes_detected == 'true' && github.event_name == 'pull_request' }} + needs: claude-auto-pr + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + token: ${{ secrets.DISPATCH_TOKEN }} + fetch-depth: 0 + + - name: Download patch artifact + uses: actions/download-artifact@v4 + with: + name: claude-changes-patch + path: /tmp/patch + + - name: Apply patch and create draft PR + env: + GH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} + run: | + git config user.name "Claude Bot" + git config user.email "claude-bot@users.noreply.github.com" + if [ ! -s /tmp/patch/claude-changes.patch ]; then + echo "::warning::Patch file is empty, nothing to apply" + exit 0 + fi + + BASE_BRANCH="${{ github.event.pull_request.base.ref }}" + NEW_BRANCH="claude-patch-${{ github.run_id }}" + + git checkout -b "$NEW_BRANCH" + git apply /tmp/patch/claude-changes.patch + git add -A + git commit -m "AI: apply Claude suggestions" + git push origin "$NEW_BRANCH" + + gh pr create \ + --base "$BASE_BRANCH" \ + --head "$NEW_BRANCH" \ + --title "[Claude] Auto patch from run ${{ github.run_id }}" \ + --body "This PR was generated by applying the Claude patch artifact. Please review before merging." \ + --draft + + echo "::notice::Created draft PR from branch $NEW_BRANCH to $BASE_BRANCH" diff --git a/.github/workflows/_claude-code.yml b/.github/workflows/_claude-code.yml new file mode 100644 index 0000000..a5d5dd5 --- /dev/null +++ b/.github/workflows/_claude-code.yml @@ -0,0 +1,584 @@ +# _claude-code.yml +# Claude Code Handler — 仅支持 workflow_call 触发器。 +# +# 本仓库(KadenZhang3321/agent-skills)需配置以下 Secrets: +# CLAUDE_API_KEY — Anthropic API Key(集中管理,调用方无需配置) +# DISPATCH_TOKEN — 具备 repo 权限的 PAT(用于读写调用方仓库、发布评论、创建 PR) + +name: Claude Code Handler + +on: + workflow_call: + inputs: + caller_repo: + description: '调用方仓库路径 (owner/repo)' + required: true + type: string + custom_prompt: + description: '自定义 prompt(可选)' + required: false + type: string + default: '' + skill_name: + description: 'Skill 名称(可选)' + required: false + type: string + default: '' + skill_url: + description: 'Skill URL(可选)' + required: false + type: string + default: '' + allow_code_change: + description: '是否允许代码修改' + required: false + type: boolean + default: false + model: + description: 'Claude 模型名称' + required: false + type: string + default: 'claude-sonnet-4-20250514' + dangerously_skip_permissions: + description: '跳过权限确认' + required: false + type: boolean + default: false + allowed_tools: + description: '允许的工具列表' + required: false + type: string + default: '' + show_full_output: + description: '显示完整输出' + required: false + type: boolean + default: false + caller_env_vars: + description: '调用方环境变量(\n 分隔)' + required: false + type: string + default: '' + caller_image: + description: '自定义容器镜像' + required: false + type: string + default: '' + verify_command: + description: '验证命令' + required: false + type: string + default: '' + pr_number: + description: 'PR 编号' + required: false + type: string + default: '' + issue_number: + description: 'Issue 编号' + required: false + type: string + default: '' + comment_author: + description: '评论作者' + required: false + type: string + default: 'workflow' + comment_body: + description: '评论内容' + required: false + type: string + default: '' + secrets: + DISPATCH_TOKEN: + description: 'GitHub PAT(repo 权限)' + required: true + CLAUDE_API_KEY: + description: 'Anthropic API Key' + required: true + outputs: + changes_detected: + description: '是否检测到代码变更' + value: ${{ jobs.claude-code.outputs.changes_detected }} + commit_sha: + description: '提交 SHA(如有变更)' + value: ${{ jobs.claude-code.outputs.commit_sha }} + pr_url: + description: '创建的 PR URL(如有)' + value: ${{ jobs.claude-code.outputs.pr_url }} + cost_usd: + description: 'API 调用成本(USD)' + value: ${{ jobs.claude-code.outputs.cost_usd }} + input_tokens: + description: '输入 token 数' + value: ${{ jobs.claude-code.outputs.input_tokens }} + output_tokens: + description: '输出 token 数' + value: ${{ jobs.claude-code.outputs.output_tokens }} + response_summary: + description: 'Claude 回复摘要' + value: ${{ jobs.claude-code.outputs.response_summary }} + error_message: + description: '错误信息(如有)' + value: ${{ jobs.claude-code.outputs.error_message }} + +jobs: + claude-code: + name: Handle Claude Request + runs-on: ubuntu-latest + timeout-minutes: 60 + container: + image: ${{ inputs.caller_image || 'ubuntu:24.04' }} + options: --user root + outputs: + changes_detected: ${{ steps.claude_run.outputs.changes_detected }} + commit_sha: ${{ steps.claude_run.outputs.commit_sha }} + pr_url: ${{ steps.claude_run.outputs.pr_url }} + cost_usd: ${{ steps.claude_run.outputs.cost_usd }} + input_tokens: ${{ steps.claude_run.outputs.input_tokens }} + output_tokens: ${{ steps.claude_run.outputs.output_tokens }} + response_summary: ${{ steps.claude_run.outputs.response_summary }} + error_message: ${{ steps.claude_run.outputs.error_message }} + + steps: + # Checkout agent-skills(用于读取白名单和 Skill 文件) + - name: Checkout agent-skills + uses: actions/checkout@v4 + with: + repository: KadenZhang3321/agent-skills + ref: main + path: agent-skills + + # Install base dependencies + - name: Install base dependencies + if: inputs.caller_image == '' + run: | + apt-get update && apt-get install -y python3 python3-pip curl git bash gh + rm -rf /var/lib/apt/lists/* + shell: bash + + - name: Setup Node.js + if: inputs.caller_image == '' + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Claude Code + run: | + if ! command -v claude &> /dev/null; then + npm install -g @anthropic-ai/claude-code + fi + claude --version + shell: bash + + # 白名单校验 + - name: Check caller whitelist + env: + CALLER_REPO: ${{ inputs.caller_repo }} + run: | + python3 - <<'PYEOF' + import json, os, sys + caller_repo = os.environ['CALLER_REPO'] + caller_org = caller_repo.split('/')[0] + with open('agent-skills/.github/allowed-callers.json') as f: + config = json.load(f) + if caller_org in config.get('allowed_orgs', []) or caller_repo in config.get('allowed_repos', []): + print(f'Caller {caller_repo} is whitelisted') + else: + print(f'::error::Caller {caller_repo} is not in the whitelist. ' + f'Update .github/allowed-callers.json to grant access.') + sys.exit(1) + PYEOF + shell: bash + + # 检查触发者在调用方仓库的权限 + - name: Check user permission + env: + GH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} + CALLER_REPO: ${{ inputs.caller_repo }} + ACTOR: ${{ inputs.comment_author }} + run: | + PERMISSION=$(curl -sf \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/$CALLER_REPO/collaborators/$ACTOR/permission" \ + | python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','none'))") + + if [[ "$PERMISSION" == "write" || "$PERMISSION" == "admin" || "$PERMISSION" == "maintain" ]]; then + echo "::$ACTOR has $PERMISSION permission on $CALLER_REPO" + else + echo "::error::$ACTOR has insufficient permission ($PERMISSION) on $CALLER_REPO" + exit 1 + fi + shell: bash + + # Checkout 调用方仓库 + - name: Checkout caller repo + uses: actions/checkout@v4 + with: + repository: ${{ inputs.caller_repo }} + token: ${{ secrets.DISPATCH_TOKEN }} + path: caller-repo + fetch-depth: 0 + + # 设置调用方仓库的环境变量 + - name: Setup caller environment variables + if: inputs.caller_env_vars != '' + run: | + echo "${{ inputs.caller_env_vars }}" >> $GITHUB_ENV + echo "::notice::Loaded caller environment variables" + shell: bash + + # 拉取 PR Diff + - name: Fetch PR diff + if: inputs.pr_number != '' + env: + GH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} + CALLER_REPO: ${{ inputs.caller_repo }} + PR_NUMBER: ${{ inputs.pr_number }} + run: | + curl -sf \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github.v3.diff" \ + "https://api.github.com/repos/$CALLER_REPO/pulls/$PR_NUMBER" \ + -o /tmp/pr.diff \ + || touch /tmp/pr.diff + echo "PR diff: $(wc -c < /tmp/pr.diff) bytes" + shell: bash + + # 构建 prompt + - name: Build prompt + env: + CALLER_REPO: ${{ inputs.caller_repo }} + PR_NUMBER: ${{ inputs.pr_number }} + COMMENT_BODY: ${{ inputs.comment_body }} + COMMENT_AUTHOR: ${{ inputs.comment_author }} + SKILL_NAME: ${{ inputs.skill_name }} + SKILL_URL: ${{ inputs.skill_url }} + CUSTOM_PROMPT: ${{ inputs.custom_prompt }} + ALLOW_CODE_CHANGE: ${{ inputs.allow_code_change }} + run: | + python3 - <<'PYEOF' + import os + + caller_repo = os.environ.get('CALLER_REPO', '') + pr_number = os.environ.get('PR_NUMBER', '') or '' + comment_body = os.environ.get('COMMENT_BODY', '') or '' + comment_author = os.environ.get('COMMENT_AUTHOR', '') + skill_name = os.environ.get('SKILL_NAME', '') or '' + skill_url = os.environ.get('SKILL_URL', '') or '' + custom_prompt = os.environ.get('CUSTOM_PROMPT', '') or '' + allow_code_change = os.environ.get('ALLOW_CODE_CHANGE', 'false').lower() == 'true' + + skill_prompt = '' + + if custom_prompt: + skill_prompt = custom_prompt + print(f'Using custom_prompt directly') + elif skill_url: + try: + import urllib.request + skill_prompt = urllib.request.urlopen(skill_url).read().decode() + print(f'Loaded skill from URL: {skill_url}') + except Exception as e: + print(f'Warning: failed to load skill from URL: {e}') + skill_prompt = '' + elif skill_name: + candidates = [ + f'caller-repo/.github/skills/{skill_name}/SKILL.md', + f'caller-repo/.ai/skills/{skill_name}/SKILL.md', + f'agent-skills/skills/{skill_name}/SKILL.md', + ] + for skill_path in candidates: + try: + skill_prompt = open(skill_path).read() + print(f'Loaded skill from: {skill_path}') + break + except FileNotFoundError: + continue + if not skill_prompt: + print(f'Warning: skill "{skill_name}" not found in any of: {candidates}') + + parts = [f'You are a code assistant for repository **{caller_repo}**. Always respond in the same language as the user\'s request. If the user writes in Chinese, respond in Chinese.'] + + if skill_prompt: + parts.append(f'## Skill Instructions\n\n{skill_prompt}') + + if pr_number: + parts.append(f'You are reviewing **PR #{pr_number}** in {caller_repo}.') + try: + diff = open('/tmp/pr.diff').read().strip() + if diff: + if len(diff) > 100_000: + diff = diff[:100_000] + '\n\n[diff truncated at 100KB]' + parts.append(f'## PR Diff\n\n```diff\n{diff}\n```') + except FileNotFoundError: + pass + + if comment_body.strip(): + parts.append(f'## Request from @{comment_author}\n\n{comment_body.strip()}') + + if allow_code_change: + parts.append( + 'The repository is checked out in the current working directory.\n' + 'You have FULL permission to **edit, create, and delete files** to fix issues or implement improvements.\n' + 'Please directly make the necessary code changes, then run verify_command to validate.\n' + 'After making changes, summarize what you changed and why.' + ) + else: + parts.append( + 'The repository is checked out in the current working directory.\n' + 'Provide a detailed code review with specific, actionable suggestions.\n' + 'Do NOT modify any files.' + ) + + prompt = '\n\n'.join(parts) + + with open(os.environ['GITHUB_ENV'], 'a') as f: + f.write('CLAUDE_PROMPT</tmp/claude_stderr.txt \ + > /tmp/claude_raw.json + EXIT_CODE=$? + set -e + + if [[ "$SHOW_FULL_OUTPUT" == "true" ]]; then + echo "::endgroup::" + fi + + if [[ $EXIT_CODE -eq 0 ]]; then + echo "=== Claude Code succeeded on attempt $attempt ===" + break + fi + + if [[ $attempt -lt $MAX_RETRIES ]]; then + WAIT_TIME=$((10 * attempt)) + echo "=== Claude Code failed (exit $EXIT_CODE). Retrying in ${WAIT_TIME}s... ===" + sleep $WAIT_TIME + fi + done + + # 导出 EXIT_CODE 供 Python 使用 + echo "EXIT_CODE=$EXIT_CODE" >> $GITHUB_ENV + + if [[ $EXIT_CODE -ne 0 ]]; then + echo "::error::Claude Code failed after $MAX_RETRIES attempts" + fi + + # 解析 JSON 输出 + python3 - <<'PYEOF' + import json, os, sys + + exit_code = int(os.environ.get('EXIT_CODE', '1')) + error_message = '' + + try: + data = json.loads(open('/tmp/claude_raw.json').read()) + result = data.get('result', '') + cost = data.get('cost_usd', data.get('total_cost_usd', 0)) or 0 + usage = data.get('usage', {}) + input_tokens = usage.get('input_tokens', 'N/A') + output_tokens = usage.get('output_tokens', 'N/A') + + # 提取回复摘要(前 500 字符) + response_summary = result[:500].replace('\n', ' ').replace('\r', ' ') if result else 'No response' + except Exception as e: + print(f'Warning: could not parse JSON output: {e}') + result = open('/tmp/claude_raw.json').read() if os.path.exists('/tmp/claude_raw.json') else '' + cost = 'N/A' + input_tokens = 'N/A' + output_tokens = 'N/A' + response_summary = 'Failed to parse Claude output' + error_message = f'JSON parse error: {str(e)}' + + # 保存完整回复 + open('/tmp/claude_output.txt', 'w').write(result) + print(f'Response summary: {response_summary[:200]}') + + # 写入 outputs + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f'cost_usd={cost}\n') + f.write(f'input_tokens={input_tokens}\n') + f.write(f'output_tokens={output_tokens}\n') + f.write(f'response_summary={response_summary}\n') + if error_message: + f.write(f'error_message={error_message}\n') + PYEOF + + # 检测代码变更 + if git status --porcelain | grep -q .; then + echo "changes_detected=true" >> "$GITHUB_OUTPUT" + + # 获取当前 commit SHA + COMMIT_SHA=$(git rev-parse HEAD) + echo "commit_sha=$COMMIT_SHA" >> "$GITHUB_OUTPUT" + + # 简化处理,实际可能需要更复杂的逻辑来检测新 PR + echo "pr_url=" >> "$GITHUB_OUTPUT" + else + echo "changes_detected=false" >> "$GITHUB_OUTPUT" + echo "commit_sha=" >> "$GITHUB_OUTPUT" + echo "pr_url=" >> "$GITHUB_OUTPUT" + fi + + exit $EXIT_CODE + + # 执行验证命令 + - name: Run verify command + if: inputs.verify_command != '' + id: verify + working-directory: caller-repo + run: | + ${{ inputs.verify_command }} + timeout-minutes: 30 + shell: bash + + # 生成 patch 文件并上传 artifact + - name: Create patch file + id: create_patch + if: steps.claude_run.outputs.changes_detected == 'true' + working-directory: caller-repo + run: | + git add -A + git diff --cached --no-color > /tmp/claude-changes.patch + git reset + PATCH_SIZE=$(wc -c < /tmp/claude-changes.patch) + echo "patch_size=$PATCH_SIZE" >> "$GITHUB_OUTPUT" + echo "patch_path=/tmp/claude-changes.patch" >> "$GITHUB_OUTPUT" + echo "::notice::Created patch file: $PATCH_SIZE bytes" + + - name: Upload patch as artifact + if: steps.create_patch.outputs.patch_path + uses: actions/upload-artifact@v4 + with: + name: claude-changes-patch + path: ${{ steps.create_patch.outputs.patch_path }} + retention-days: 1 + + # Post Claude response as comment + - name: Post comment + if: always() && inputs.issue_number != '' + shell: bash + env: + GH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} + CALLER_REPO: ${{ inputs.caller_repo }} + ISSUE_NUMBER: ${{ inputs.issue_number }} + ERROR_MESSAGE: ${{ steps.claude_run.outputs.error_message }} + run: | + if [[ -n "$ERROR_MESSAGE" ]]; then + printf -v body '## Claude Code Failed\n\n%s' "$ERROR_MESSAGE" + else + output=$(cat /tmp/claude_output.txt 2>/dev/null || echo "") + if [[ -z "$output" ]]; then + output="_Claude returned an empty response._" + fi + printf -v body '## Claude Code Analysis\n\n%s' "$output" + fi + + if gh api repos/$CALLER_REPO/issues/$ISSUE_NUMBER/comments \ + --method POST \ + --field body="$body" >/dev/null 2>&1; then + echo "Comment posted successfully" + else + echo "::warning::Failed to post comment" + fi + + # Job 执行摘要 + - name: Write job summary + if: always() + env: + CALLER_REPO: ${{ inputs.caller_repo }} + COMMENT_AUTHOR: ${{ inputs.comment_author }} + PR_NUMBER: ${{ inputs.pr_number || 'N/A' }} + MODEL: ${{ inputs.model }} + CHANGES_DETECTED: ${{ steps.claude_run.outputs.changes_detected }} + COMMIT_SHA: ${{ steps.claude_run.outputs.commit_sha || 'N/A' }} + PR_URL: ${{ steps.claude_run.outputs.pr_url || 'N/A' }} + CALLER_IMAGE: ${{ inputs.caller_image || 'N/A' }} + VERIFY_COMMAND: ${{ inputs.verify_command || 'N/A' }} + VERIFY_RESULT: ${{ steps.verify.outcome || 'N/A' }} + INPUT_TOKENS: ${{ steps.claude_run.outputs.input_tokens }} + OUTPUT_TOKENS: ${{ steps.claude_run.outputs.output_tokens }} + COST_USD: ${{ steps.claude_run.outputs.cost_usd }} + RESPONSE_SUMMARY: ${{ steps.claude_run.outputs.response_summary }} + ERROR_MESSAGE: ${{ steps.claude_run.outputs.error_message }} + run: | + python3 - <<'PYEOF' + import os + + summary_path = os.environ['GITHUB_STEP_SUMMARY'] + lines = [ + "### Claude Code Run Summary", + "", + "| Field | Value |", + "|-------|-------|", + f"| Caller repo | `{os.environ['CALLER_REPO']}` |", + f"| Triggered by | `{os.environ['COMMENT_AUTHOR']}` |", + f"| PR | `{os.environ['PR_NUMBER']}` |", + f"| Model | `{os.environ['MODEL']}` |", + f"| Changes detected | `{os.environ['CHANGES_DETECTED']}` |", + f"| Commit SHA | `{os.environ['COMMIT_SHA']}` |", + f"| PR URL | `{os.environ['PR_URL']}` |", + f"| Caller image | `{os.environ['CALLER_IMAGE']}` |", + f"| Verify command | `{os.environ['VERIFY_COMMAND']}` |", + f"| Verify result | `{os.environ['VERIFY_RESULT']}` |", + f"| Input tokens | `{os.environ['INPUT_TOKENS']}` |", + f"| Output tokens | `{os.environ['OUTPUT_TOKENS']}` |", + f"| Cost (USD) | `{os.environ['COST_USD']}` |", + f"| Response summary | `{os.environ['RESPONSE_SUMMARY']}` |", + ] + + error_msg = os.environ.get('ERROR_MESSAGE', '') + if error_msg: + lines.append(f"| **Error** | `{error_msg}` |") + + with open(summary_path, 'a') as f: + f.write('\n'.join(lines) + '\n') + PYEOF diff --git a/EXAMPLE-USAGE.md b/EXAMPLE-USAGE.md new file mode 100644 index 0000000..d8dfccb --- /dev/null +++ b/EXAMPLE-USAGE.md @@ -0,0 +1,287 @@ +# Agent-Skills 调用指南 + +本文档说明如何在业务仓库中使用中心 Claude Code 服务。 + +## 目录 + +- [架构概述](#架构概述) +- [前置条件](#前置条件) +- [同步调用(workflow_call)](#同步调用workflow_call) +- [参数说明](#参数说明) +- [输出说明](#输出说明) +- [错误处理](#错误处理) +- [常见问题](#常见问题) + +## 架构概述 + +``` +┌─────────────────────┐ ┌──────────────────────────┐ +│ 业务仓库 │ │ agent-skills 中心仓库 │ +│ │ │ │ +│ - 触发工作流 │ ──────> │ _claude-code.yml │ +│ - 传递参数 │ │ - 白名单校验 │ +│ - 接收结果 │ <────── │ - 权限检查 │ +│ │ │ - 调用 Claude Code │ +└─────────────────────┘ │ - 返回结果 │ + └──────────────────────────┘ +``` + +**调用方式**:通过 `workflow_call` 同步调用,父工作流会等待 Claude 执行完毕。 + +## 前置条件 + +### 1. 配置 Secrets + +在业务仓库的 Settings → Secrets and variables → Actions 中添加: + +| Secret 名称 | 说明 | 权限要求 | +|-------------|------|----------| +| `DISPATCH_TOKEN` | GitHub Personal Access Token | `repo` 完整权限 | +| `CLAUDE_API_KEY` | Anthropic API Key | 无需额外权限 | + +### 2. 添加白名单 + +联系 agent-skills 仓库管理员,将你的仓库添加到 `.github/allowed-callers.json`: + +```json +{ + "allowed_orgs": ["your-org"], + "allowed_repos": ["your-org/your-repo"] +} +``` + +## 同步调用(workflow_call) + +### 基本用法 + +```yaml +jobs: + claude: + uses: KadenZhang3321/agent-skills/.github/workflows/_claude-code.yml@main + with: + caller_repo: ${{ github.repository }} + custom_prompt: '请分析这个 PR 的代码变更' + allow_code_change: false + model: 'claude-sonnet-4-20250514' + secrets: + DISPATCH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} + CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} +``` + +### 完整示例 + +```yaml +jobs: + claude: + uses: KadenZhang3321/agent-skills/.github/workflows/_claude-code.yml@main + with: + # 必填:调用方仓库路径 + caller_repo: ${{ github.repository }} + + # Prompt 相关(三选一) + custom_prompt: '请修复这个 bug:...' + skill_name: 'github-action-diagnose' + skill_url: 'https://example.com/skill.md' + + # 代码变更权限 + allow_code_change: false + + # 模型配置 + model: 'claude-sonnet-4-20250514' + dangerously_skip_permissions: false + allowed_tools: 'Bash(git *),Read,Write,Edit,Glob,Grep' + show_full_output: true + + # PR/Issue 相关 + pr_number: '123' + issue_number: '123' + comment_author: ${{ github.actor }} + comment_body: '请分析这个 PR' + + # 环境变量 + caller_env_vars: 'KEY1=value1\nKEY2=value2' + + # 容器镜像 + caller_image: 'ubuntu:24.04' + + # 验证命令 + verify_command: 'pytest tests/' + secrets: + DISPATCH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} + CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} + + # 使用 Claude 的输出 + post-process: + needs: claude + runs-on: ubuntu-latest + steps: + - name: Check result + run: | + echo "Changes: ${{ needs.claude.outputs.changes_detected }}" + echo "Cost: ${{ needs.claude.outputs.cost_usd }}" +``` + +### PR 评论触发示例 + +```yaml +on: + issue_comment: + types: [created] + +jobs: + claude-on-comment: + if: | + github.event.issue.pull_request && + contains(github.event.comment.body, '@claude') && + !endsWith(github.actor, '[bot]') && + github.actor != 'dependabot' + uses: KadenZhang3321/agent-skills/.github/workflows/_claude-code.yml@main + with: + caller_repo: ${{ github.repository }} + pr_number: ${{ github.event.issue.number }} + issue_number: ${{ github.event.issue.number }} + comment_author: ${{ github.event.comment.user.login }} + comment_body: ${{ github.event.comment.body }} + allow_code_change: false + model: 'claude-sonnet-4-20250514' + secrets: + DISPATCH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} + CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} +``` + +更多调用模板请参考 [.github/workflows/TEMPLATE-CALLER.yml](../.github/workflows/TEMPLATE-CALLER.yml)。 + +## 参数说明 + +### workflow_call inputs + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `caller_repo` | string | ✅ | - | 调用方仓库路径 (owner/repo) | +| `custom_prompt` | string | ❌ | `''` | 自定义 prompt 内容 | +| `skill_name` | string | ❌ | `''` | Skill 名称(从 agent-skills 加载) | +| `skill_url` | string | ❌ | `''` | Skill URL(从远程加载) | +| `allow_code_change` | boolean | ❌ | `false` | 是否允许 Claude 修改代码 | +| `model` | string | ❌ | `claude-sonnet-4-20250514` | Claude 模型名称 | +| `dangerously_skip_permissions` | boolean | ❌ | `false` | 跳过权限确认 | +| `allowed_tools` | string | ❌ | `''` | 允许的工具列表(逗号分隔) | +| `show_full_output` | boolean | ❌ | `false` | 显示完整输出 | +| `caller_env_vars` | string | ❌ | `''` | 调用方环境变量(`\n` 分隔) | +| `caller_image` | string | ❌ | `''` | 自定义容器镜像 | +| `verify_command` | string | ❌ | `''` | 验证命令 | +| `pr_number` | string | ❌ | `''` | PR 编号 | +| `issue_number` | string | ❌ | `''` | Issue 编号 | +| `comment_author` | string | ❌ | `'workflow'` | 评论作者 | +| `comment_body` | string | ❌ | `''` | 评论内容 | + +### secrets + +| Secret | 必填 | 说明 | +|--------|------|------| +| `DISPATCH_TOKEN` | ✅ | GitHub PAT(repo 权限) | +| `CLAUDE_API_KEY` | ✅ | Anthropic API Key | + +## 输出说明 + +### workflow_call outputs + +| 输出 | 类型 | 说明 | +|------|------|------| +| `changes_detected` | boolean | 是否检测到代码变更 | +| `commit_sha` | string | 提交 SHA(如有变更) | +| `pr_url` | string | 创建的 PR URL(如有) | +| `cost_usd` | number | API 调用成本(USD) | +| `input_tokens` | number | 输入 token 数 | +| `output_tokens` | number | 输出 token 数 | +| `response_summary` | string | Claude 回复摘要(前 500 字符) | +| `error_message` | string | 错误信息(如有) | + +### 使用示例 + +```yaml +jobs: + claude: + uses: KadenZhang3321/agent-skills/.github/workflows/_claude-code.yml@main + with: + caller_repo: ${{ github.repository }} + custom_prompt: '修复这个 bug' + allow_code_change: true + secrets: + DISPATCH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} + CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} + + process-result: + needs: claude + runs-on: ubuntu-latest + steps: + - name: Check if code changed + if: needs.claude.outputs.changes_detected == 'true' + run: | + echo "Claude made changes!" + echo "Commit: ${{ needs.claude.outputs.commit_sha }}" + echo "Cost: ${{ needs.claude.outputs.cost_usd }} USD" + + - name: Handle error + if: needs.claude.outputs.error_message != '' + run: | + echo "Error: ${{ needs.claude.outputs.error_message }}" + exit 1 +``` + +## 错误处理 + +### 重试机制 + +Claude 调用失败时会自动重试(最多 3 次,指数退避:10s → 20s → 30s)。 + +### 错误输出 + +如果最终失败,`error_message` 输出会包含错误信息: + +```yaml +- name: Handle error + if: needs.claude.outputs.error_message != '' + run: | + echo "Claude failed: ${{ needs.claude.outputs.error_message }}" +``` + +### 常见错误 + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `Caller not in whitelist` | 仓库未在白名单中 | 联系管理员添加 | +| `Insufficient permission` | 用户权限不足 | 确保用户有 write/admin 权限 | +| `Claude Code failed after 3 attempts` | API 调用失败 | 检查 API Key 和网络 | +| `JSON parse error` | 输出解析失败 | 检查模型配置 | + +## 常见问题 + +### Q: 同步调用和异步调用应该选哪个? + +**A**: 当前只支持 `workflow_call` 同步调用。父工作流会等待 Claude 执行完成,适合需要立即获取结果、后续步骤依赖 Claude 输出的场景。 + +### Q: 如何允许 Claude 修改代码? + +**A**: 设置 `allow_code_change: true`,Claude 会修改文件并生成 patch artifact,可从 Actions 下载应用。 + +### Q: 如何自定义容器镜像? + +**A**: 设置 `caller_image: 'your-image:tag'`,可以跳过依赖安装步骤,加速执行。 + +### Q: 如何传递环境变量给 Claude? + +**A**: 使用 `caller_env_vars`,格式为 `KEY1=value1\nKEY2=value2`。 + +### Q: Claude 修改的代码如何获取? + +**A**: 同步调用时,通过 artifact 下载 `claude-changes-patch`。 + +### Q: 如何指定 Claude 使用的模型? + +**A**: 设置 `model` 参数,如 `model: 'claude-sonnet-4-20250514'`。 + +### Q: 白名单如何配置? + +**A**: 在 agent-skills 仓库的 `.github/allowed-callers.json` 中添加: +- `allowed_orgs`: 整个组织的仓库都可以调用 +- `allowed_repos`: 单独指定仓库 diff --git a/README.md b/README.md index 2e0bf28..94a065c 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ git push origin feature/add-new-skill 在 Claude Code 中运行以下命令: ``` - /plugin marketplace add opensourceways/agent-skills + /plugin marketplace add KadenZhang3321/agent-skills ``` 2. **浏览并安装skill** @@ -79,14 +79,14 @@ git push origin feature/add-new-skill 在 Claude Code 中输入 `/plugin` 或点击插件图标,然后: - 选择 `Browse and install plugins`(浏览并安装插件) - - 选择 `opensourceways-agent-skills` marketplace + - 选择 `KadenZhang3321-agent-skills` marketplace - 选择你想要安装的技能(例如 `triton-upgrade`) - 点击 `Install now`(立即安装) **方式 B:命令行直接安装** ``` - /plugin install triton-upgrade@opensourceways-agent-skills + /plugin install triton-upgrade@KadenZhang3321-agent-skills ``` @@ -100,12 +100,12 @@ git push origin feature/add-new-skill ```json { - "opensourceways-agent-skills": { + "KadenZhang3321-agent-skills": { "source": { "source": "github", - "repo": "opensourceways/agent-skills" + "repo": "KadenZhang3321/agent-skills" }, - "installLocation": "~/.claude/plugins/marketplaces/opensourceways-agent-skills", + "installLocation": "~/.claude/plugins/marketplaces/KadenZhang3321-agent-skills", "lastUpdated": "2026-02-13T00:00:00.000Z" } } @@ -118,7 +118,7 @@ git push origin feature/add-new-skill 在终端中运行安装命令: ```bash - claude plugin install triton-upgrade@opensourceways-agent-skills + claude plugin install triton-upgrade@KadenZhang3321-agent-skills ``` @@ -277,6 +277,77 @@ A: Claude Code 的配置文件通常在: - 全局配置:`~/.claude/config.json` - 项目配置:`/.claude/config.json` +## 🤖 Claude Bot 集成指南 + +如何将 Claude Bot 集成到组织下的任意仓库,请参阅:[`.github/workflows/README.md`](.github/workflows/README.md) + +## 🚀 Claude Code 中心服务调用指南 + +本仓库提供中心化的 Claude Code 调用服务,其他业务仓库可以通过 `workflow_call` 方式调用。 + +### 架构优势 + +- **集中管理 API Key**:调用方无需配置 Anthropic API Key +- **统一权限控制**:通过白名单和权限检查确保安全 +- **自动重试机制**:失败时自动重试(最多 3 次) +- **预构建镜像支持**:可选使用预装 Claude CLI 的容器镜像,加速执行 + +### 快速开始 + +```yaml +jobs: + claude: + uses: KadenZhang3321/agent-skills/.github/workflows/_claude-code.yml@main + with: + caller_repo: ${{ github.repository }} + custom_prompt: '请分析这个 PR 的代码变更' + allow_code_change: false + model: 'claude-sonnet-4-20250514' + secrets: + DISPATCH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} + CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} +``` + +### 前置条件 + +1. **配置 Secrets**(在业务仓库的 Settings → Secrets 中): + - `DISPATCH_TOKEN`:GitHub PAT(需要 `repo` 权限) + - `CLAUDE_API_KEY`:Anthropic API Key + +2. **添加白名单**:联系本仓库管理员,将你的仓库添加到 `.github/allowed-callers.json` + +### 详细文档 + +- **完整调用示例和参数说明**:[EXAMPLE-USAGE.md](EXAMPLE-USAGE.md) +- **调用模板文件**:[.github/workflows/TEMPLATE-CALLER.yml](.github/workflows/TEMPLATE-CALLER.yml) +- **从直接 CC 调用迁移**:[docs/migration-guide.md](docs/migration-guide.md) + +### 预构建容器镜像 + +为了加速执行,可以使用预装 Claude CLI 的容器镜像: + +```dockerfile +# Dockerfile 位于仓库根目录 +# 构建命令: +# docker build -t kadenzhang3321/claude-runner:latest . +# docker push kadenzhang3321/claude-runner:latest +``` + +在调用时指定 `caller_image` 参数: + +```yaml +jobs: + claude: + uses: KadenZhang3321/agent-skills/.github/workflows/_claude-code.yml@main + with: + caller_repo: ${{ github.repository }} + caller_image: 'kadenzhang3321/claude-runner:latest' + custom_prompt: '请分析代码' + secrets: + DISPATCH_TOKEN: ${{ secrets.DISPATCH_TOKEN }} + CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} +``` + ## 📞 联系方式 如有问题或建议,请: @@ -291,4 +362,4 @@ A: Claude Code 的配置文件通常在: --- **维护者**: [添加维护者信息] -**最后更新**: 2026-02-03 +**最后更新**: 2026-04-15 diff --git a/skills/infrastructure/README.md b/skills/infrastructure/README.md index 8f6fbd3..3942c7b 100644 --- a/skills/infrastructure/README.md +++ b/skills/infrastructure/README.md @@ -5,6 +5,7 @@ ## 团队职责 Infrastructure 团队负责技术设施建设,包括: + - 基础设施搭建和维护 - DevOps 工具链建设 - CI/CD 流水线优化 @@ -13,7 +14,7 @@ Infrastructure 团队负责技术设施建设,包括: ## 目录结构建议 -``` +```text infrastructure/ ├── README.md # 本文件 ├── devops/ # DevOps 相关 @@ -30,74 +31,67 @@ infrastructure/ | 名字 | 功能 | | --- | --- | -| Github-action-diagnose | 诊断昇腾CI github action问题 | +| [github-action-diagnose](./github-action-diagnose/SKILL.md) | 诊断昇腾 CI GitHub Action 失败,定位基础设施故障与根因 | +| [claude-bot](./claude-bot/claude.md) | GitHub Issues/PR 中响应 `@claude` 的 AI 助手 | ### Github-action-diagnose #### 使用示例 -**示例 1:通过 GitHub Actions Run URL 诊断** +#### 示例 1:通过 GitHub Actions Job URL 诊断(推荐,精确到单个 Job) -``` -/github-action-diagnose https://github.com/my-org/my-repo/actions/runs/12345678901 +```text +/github-action-diagnose https://github.com/vllm-project/vllm-ascend/actions/runs/23656949484/job/68954806226 ``` -技能会自动: -1. 提取 `owner/repo` = `my-org/my-repo`,`run_id` = `12345678901` -2. 执行 `gh run view 12345678901 --repo my-org/my-repo --log-failed` -3. 进入 5 步诊断流程 +#### 示例 2:通过 Run URL 诊断(自动分析所有失败 Job) ---- +```text +/github-action-diagnose https://github.com/vllm-project/vllm-ascend/actions/runs/23282406275 +``` -**示例 2:通过 Run ID 诊断(在 repo 目录下)** +#### 示例 3:通过 Run ID 诊断 +```text +/github-action-diagnose 23282406275 ``` -/github-action-diagnose 12345678901 -``` - -技能会从当前目录推断 repo,然后执行诊断。 --- -**示例 3:直接粘贴日志内容** +#### 示例输出 A:基础设施故障(Artifact 上传超时) -``` -/github-action-diagnose -2024-01-15T10:23:45.123Z [error] npu-smi info: ERR99999 Device not found -2024-01-15T10:23:45.456Z [error] error code 507035 -2024-01-15T10:23:46.000Z Process exited with code 1 -``` +```text +# CI 故障诊断报告 ---- +**Run**: Nightly-A3 #23656949484 +**PR**: vllm-project/vllm-ascend (main) +**时间**: 2026-03-27T21:21:23Z ~ 2026-03-27T21:48:37Z -**示例输出 A:基础设施故障** +## 故障一:DeepSeek-V3.1-BF16.yaml (multi-node-deepseek-v3.1) -``` -# GitHub Action 故障诊断报告 - -## 1. 故障概览 -- **任务名称**: train-resnet50 -- **Run ID**: 12345678901 -- **故障定性**: 硬件故障 -- **判定依据**: NPU 驱动报 ERR99999,Device not found - -## 2. 详细诊断 -- **失败步骤**: Run steps (pytest) -- **报错原文**: `npu-smi info: ERR99999 error code 507035` -- **物理节点**: a3-runner-0023 -> Pod a3-0/runner-abc -> node-a3-12 -- **根因分析**: 物理机 node-a3-12 上 NPU 设备驱动异常,设备不可用 +- **定性**: 环境问题(基础设施) +- **根因**: Upload logs 步骤上传 Artifact 时网络超时,实际测试已成功完成 +- **关键标识**: `Failed to CreateArtifact: Unable to make request: ETIMEDOUT` +- **责任方**: 基础设施团队 +- **建议**: 重跑;检查 Runner 到 GitHub 的出口网络 ``` ---- +#### 示例输出 B:代码 Bug -**示例输出 B:非基础设施问题** +```text +# CI 故障诊断报告 -``` -未发现环境/基础设施故障。 +**Run**: PR CI #xxx +**PR**: vllm-project/vllm-ascend#1234 (feature/xxx) +**时间**: ... + +## 故障一:e2e-singlecard-light -分析依据:调度正常(Set up job 完成)、镜像拉取成功、NPU 挂载无报错。 -失败发生在业务代码层:Python Traceback 指向 tests/test_model.py:42, -AssertionError: expected accuracy > 0.8, got 0.75。 +- **定性**: 代码 Bug(PR 引入) +- **根因**: UnboundLocalError,PR 修改了 import 路径但未处理 ImportError 分支 +- **关键标识**: `UnboundLocalError: cannot access local variable 'huggingface_hub'` +- **责任方**: PR 作者 +- **建议**: 修改 vllm_ascend/xxx.py,在 except ImportError 分支设置默认值 ``` ## 团队规范 diff --git a/skills/infrastructure/claude-bot/claude.md b/skills/infrastructure/claude-bot/claude.md new file mode 100644 index 0000000..67ddffc --- /dev/null +++ b/skills/infrastructure/claude-bot/claude.md @@ -0,0 +1,120 @@ +# claude-bot + +## 描述 + +集成于 GitHub Issues / Pull Requests 的 AI 助手,在 vllm-project 组织内响应 `@claude` 提及,自动回复技术问题、代码审查意见和 CI 诊断建议。 + +## 使用场景 + +- 在 Issue 中 `@claude` 提问:解答 vLLM / Ascend NPU 相关技术问题 +- 在 PR 中 `@claude` 请求代码审查:检查正确性、性能、硬件兼容性 +- 新 Issue 创建时自动触发:对问题进行初步分类和响应 + +## 前置要求 + +- 调用方仓库需配置 GitHub Actions Workflow(参见 `.github/workflows/example-caller.yml`) +- 需在仓库或组织级别设置 `CLAUDE_API_KEY` Secret +- 触发者须为组织成员且拥有仓库写权限(write / admin) + +## 参数 + +本 skill 通过 GitHub 事件触发,无命令行参数。触发方式: + +| 触发事件 | 条件 | 说明 | +|---------|------|------| +| `issue_comment` | 评论包含 `@claude` | 回复指定评论 | +| `issues: opened` | 新建 Issue | 自动响应新 Issue | + +## 使用方法 + +在 GitHub Issue 或 PR 的评论中直接提及: + +``` +@claude 这个 PR 的 Ascend NPU 兼容性如何? +``` + +``` +@claude 帮我看下这段代码有没有问题 +``` + +## 示例 + +### 示例 1:代码审查请求 + +在 PR 评论中: +``` +@claude 请审查这次对 vllm_ascend/executor.py 的修改 +``` + +**预期输出**:结构化的代码审查意见,包含正确性、性能和 Ascend NPU 兼容性分析。 + +### 示例 2:技术问题咨询 + +在 Issue 评论中: +``` +@claude CANN 8.0 下 flash attention 算子覆盖情况如何? +``` + +**预期输出**:针对 Ascend NPU / CANN 的技术解答。 + +## 注意事项 + +- 仅响应组织成员(vllm-project)且拥有仓库写权限的用户 +- 机器人账号(`[bot]`、`dependabot`)触发的事件自动跳过 +- 响应语言与触发评论一致(中文评论回中文,英文评论回英文) +- 不对修复方案或操作步骤做出保证,建议以人工审核为准 + +## 相关 Skills + +- [github-action-diagnose](../github-action-diagnose/SKILL.md) — CI 故障根因诊断 + +## 更新日志 + +### v1.0.0 (2026-03-31) +- 初始版本:支持 Issue/PR 评论触发,单认证模式(api_key),Token 用量展示 + +## 作者 + +@zhangyang + +## 最后更新 + +2026-03-31 + +--- + +# Claude Bot Behavior Guide + +> 以下为 Claude Bot 在 vllm-project 组织内的行为规范,供 AI 模型参考。 + +You are an AI assistant integrated into GitHub issues and pull requests for the vllm-project organization. + +## Behavior Guidelines + +- Be helpful, concise, and professional. +- When asked to review code, focus on correctness, performance, and hardware compatibility (especially Ascend NPU). +- Do not suggest changes unrelated to the repository's scope. +- If you are unsure, ask for clarification. + +## Context + +- You are operating within the **vllm-project** GitHub organization. +- Repositories in this org include `vllm-ascend` (Ascend NPU backend) and related infrastructure. +- Common topics: vLLM inference engine, Ascend NPU support, CANN/MindSpore compatibility, CI/CD pipelines. + +## Code Review Focus + +When reviewing pull requests or code snippets: + +1. **Correctness** — Does the code do what it claims? Are there edge cases or off-by-one errors? +2. **Performance** — Are there obvious bottlenecks, unnecessary allocations, or missed optimizations? +3. **Hardware compatibility** — Does the code respect Ascend NPU constraints (operator coverage, memory layout, CANN API version)? +4. **Test coverage** — Are the changes tested? Are existing tests likely to break? +5. **Documentation** — Do public APIs have docstrings? Are complex sections commented? + +## Tone + +- Use Markdown formatting in your responses when it improves readability. +- Be direct and actionable. Prefer "Change X to Y because Z" over vague suggestions. +- Acknowledge uncertainty: say "I'm not sure" rather than guessing. +- Respond in the same language as the comment that triggered you (Chinese or English). diff --git a/skills/infrastructure/github-action-diagnose/SKILL.md b/skills/infrastructure/github-action-diagnose/SKILL.md index 5e1fc11..bb01ba3 100644 --- a/skills/infrastructure/github-action-diagnose/SKILL.md +++ b/skills/infrastructure/github-action-diagnose/SKILL.md @@ -1,99 +1,422 @@ --- name: github-action-diagnose -description: 诊断昇腾(Ascend)NPU 集群上 GitHub Actions 执行失败的原因,定位基础设施故障与根因分析 -allowed-tools: Bash(gh run view:*), Bash(gh run list:*), Bash(gh api:*), Bash(kubectl get:*), Bash(kubectl describe:*), Bash(kubectl logs:*), Bash(kubectl config:*), Read, Grep +description: GitHub Action CI 失败自动诊断,专为昇腾(Ascend)NPU 集群(ARC/K8s)基础设施设计。当用户提到 CI 失败、流水线挂掉、CI 报错、构建失败、测试失败、PR CI 不过、CI 排队超时、nightly 失败、runner 异常等情况时立即触发。自动分析日志、定位物理节点、分类根因(基础设施/代码Bug/精度回归)、识别责任方,将诊断报告写入文件。即使用户只是粘贴了一段错误日志或 CI 链接,也应触发此 skill。 +compatibility: + tools: + - Bash + - Read + - Write + - Glob + - Grep --- -## Your task +# CI 故障自动诊断 Skill -诊断昇腾(Ascend)NPU 集群上 GitHub Actions 的执行失败。仅做故障定性和根因分析,不提供修复建议。 -所有只读操作(gh CLI、kubectl 查询、日志读取)直接执行,无需用户确认。 +## 使用场景 -本技能采用 5 步诊断流程:Step 0 输入识别 + 4 步诊断逻辑(生命周期分诊 → 物理节点溯源与环境故障定位 → 非环境问题判定 → 输出报告)。 +- Ascend NPU 集群(ARC/K8s)上的 GitHub Actions 运行失败 +- PR CI 不过、nightly 任务失败、runner 异常等需要快速定位根因的场景 +- 区分故障责任方:基础设施团队(环境/硬件)vs PR 作者(代码/精度) -### Step 0: 识别输入并获取日志 +## 前置要求 -分析用户提供的输入,自动识别类型: +- `gh` CLI 已安装并已完成 GitHub 认证(`gh auth login`) +- 有目标仓库的读权限(用于拉取 run 日志) +- `kubectl` 已配置(用于物理节点溯源,无权限时跳过该步骤) -- **GitHub Actions Run URL**(包含 `/actions/runs/`):提取 owner/repo 和 run_id,执行 `gh run view --repo --log-failed` 获取失败日志。 -- **Run ID**(纯数字):需要用户确认 repo 或从当前目录推断,然后执行 `gh run view --log-failed`。 -- **粘贴的日志内容**(非 URL 非纯数字):直接作为日志内容进行分析。 +## 核心原则 -如果 `--log-failed` 信息不足,自动尝试 `gh run view --log` 获取完整日志。 -同时获取 run 的元信息:`gh run view --repo --json jobs,name,status,conclusion,headBranch,event`。 +- **静默执行**:直接运行所有只读操作(`gh` CLI、日志读取、`kubectl` 查询),不要逐步询问权限 +- **先跑后说**:收集完所有信息后,一次性生成完整报告 +- **直接输出**:诊断结果直接在对话中输出,不写入文件(Action Plan JSON 除外) +- **责任明确**:每个问题必须标注责任方(基础设施团队 / PR 作者) +- **仅在变更操作前确认**:修改代码、提交 commit、执行 `kubectl delete/patch` 才需询问 -### Step 1: 生命周期分诊 +--- + +## Step 0:解析用户输入并确定诊断范围 + +根据用户输入确定诊断范围: + +| 输入类型 | 示例 | 诊断范围 | +|---------|------|---------| +| Run ID | `23282406275` | 诊断 Run 中所有失败的 Job | +| Run URL | `github.com/.../actions/runs/23282406275` | 诊断 Run 中所有失败的 Job | +| Job URL | `github.com/.../actions/runs/.../jobs/xxx` | **只诊断这一个 Job** | + +> **重要**:如果用户提供了具体的 Job URL,**只分析这一个 Job**,不要去拉取整个 Run 的其他 Job 日志。这是为了节省时间和 token。 + +--- + +## Step 1:静默收集上下文 + +### 1a. 确定诊断范围 + +- **如果用户提供了 Job URL**:直接使用该 Job ID,跳过 Run 级别分析 +- **如果用户提供了 Run ID/URL**:分析该 Run 中所有失败的 Job + +### 1b. 收集日志 + +**必须使用脚本获取日志**,不要直接用 `gh run view --job --log`,原因: +1. 日志可能很大(几百MB),导致 API 超时或工具调用被中断 +2. tiling 编译警告会淹没真正的根因错误 +3. 脚本已内置预过滤逻辑,只输出关键错误行 + +**Job URL 场景(只诊断单个 Job)**: +```bash +bash /scripts/fetch-run.sh --job [owner/repo] +# 例如:bash scripts/fetch-run.sh --job 68954806226 vllm-project/vllm-ascend +``` + +**Run ID/URL 场景(诊断所有失败 Job)**: +```bash +bash /scripts/fetch-run.sh [owner/repo] +# 例如:bash scripts/fetch-run.sh 23326177540 +# 默认 repo 为 vllm-project/vllm-ascend +``` + +**脚本输出内容**: +- Runner 名称、起止时间、结论 +- 失败步骤列表(哪个 step 挂了) +- Annotations(通常直接揭示根因,如 ETIMEDOUT / exit code) +- Annotations 已明确根因时自动跳过日志拉取 +- 预过滤的运行时错误(RuntimeError/AssertionError/EngineDeadError/OOM 等) +- 预过滤的编译/安装错误(依赖冲突、git dubious ownership 等) +- PR 变更文件列表 + +脚本位置:`/scripts/fetch-run.sh` + +**Step 1c:依赖解析错误优先检查(Install 阶段失败时必做)** + +脚本日志不足以定位根因时,**优先使用 Step 1d 中的依赖专用命令**搜索依赖解析错误,这是最容易被遗漏的根因: + +**为什么重要**:依赖不可用(如 `modelscope==1.35.1` 不存在)会导致后续所有步骤连锁失败,日志中会出现大量 "error"、"failed",但真正的根因只在依赖解析步骤的开头。 + +### Step 1d. 按需补充的命令(脚本输出不足以定位根因时才用) + +> **脚本已内置以下逻辑,90% 场景无需手动补充命令:** +> - 失败步骤列表(哪个 step 挂了) +> - Annotations(ETIMEDOUT / exit code 等直接结论) +> - Annotations 已明确根因时自动跳过日志拉取 +> - PR 变更文件列表 + +仅在脚本输出仍不足以定位根因时,才手动补充: + +```bash +# 依赖解析错误专用 +gh run view --job --log --repo | grep -iE "unsatisfiable|No solution found|no version of|modelscope.*error" +``` + +--- + +## Step 2:生命周期快速分诊 + +根据**失败发生的步骤**做初步判断: + +| 失败步骤 | 初步定性 | +|---------|---------| +| `Set up job` | 基础设施 — Runner 调度失败 | +| `Initialize containers` | 基础设施 — 容器运行时异常 | +| `Checkout` / `Install dependencies` | 基础设施 — 网络/挂载/权限 | +| `Run test` / `Build` | **需同时执行 Step 3 和 Step 4**,不可只做其中之一 | + +> **重要**:失败在 `Run test` / `Build` 阶段时,必须同时检查环境故障(Step 3)和代码问题(Step 4),不因"看起来是代码问题"而跳过环境检查,也不因"多 Job 同时失败"而跳过代码检查。 + +**多个 Job 同步以相同步骤失败 → 强烈的基础设施信号**,但仍需完成代码侧的快速排查。 + +--- + +## Step 3:物理节点溯源(A 类必做,Run step 失败时也应尝试) + +### 3a. 获取 runner_name + +从 `Set up job` 日志或 API 获取,格式通常为: +``` +linux-aarch64-a3-2-x51bm-runner-ksnwc + ↑节点池 ↑物理机编号 ↑pod随机后缀 +``` + +### 3b. 定位 Namespace + +```bash +# 自动发现 runner pod 所在 namespace(不确定时使用) +kubectl get pods --all-namespaces | grep +``` + +### 3c. 查物理节点(区分两种场景) + +**Pod 未销毁**: +```bash +kubectl get pod -n \ + -o custom-columns=NODE:.spec.nodeName +``` + +**Pod 已销毁**:从完整日志中搜索调度记录: +```bash +# 先拉完整日志 +gh run view --job --log --repo > full.log +grep "Successfully assigned to" full.log +# 或在 Loki/ELK 中搜索相同关键词 +``` + +### 3d. 多机任务 — 优先定位 Master 节点(Rank 0) + +当任务涉及多机(multi-node)且某节点报 `Timeout` 时,**不要孤立分析报错节点**,先找 Rank 0: + +```bash +# 方法一:从日志中找 MASTER_ADDR 环境变量 +grep "MASTER_ADDR" full.log + +# 方法二:从 RANK_TABLE_FILE 中找 rank_id=0 对应的 device_ip +grep -A5 '"rank_id": "0"' +``` + +找到 Master 节点后,优先检查其日志: +- 是否有 `Unexpected Exit` +- 是否有 NPU 驱动报错(`ERR99999`、`error code 507035`) +- 是否有进程崩溃(exit `-9`、`Bus error`) + +Master 有问题 → 从节点只是连锁超时,根因在 Master。 + +### 3e. 确认 NPU 健康 + +```bash +npu-smi info # 在对应节点上执行 +kubectl get nodes --kubeconfig=<集群kubeconfig> +``` + +--- + +## Step 4:根因分类 + +### 类型 A:基础设施 / 环境故障 +信号:容器启动失败、网络超时、NPU 硬件报错(`ERR99999`)、OOM(`Bus error` / `Killed` / exit `-9`)、exit code 255(K8s 强制终止)、ModelScope 下载超时、多机 Timeout(见 Step 3d)、`shm_broadcast` 超时 + `EngineDeadError`(Nightly 资源调度不稳定,见 `references/common-patterns.md`) + +**责任方**:基础设施团队 + +### 类型 B:代码 Bug(PR 引入) +信号:exit code 1、Python 异常堆栈(`UnboundLocalError` / `AssertionError` / `AttributeError`)、失败与 PR diff 直接对应、重跑仍失败、UT 卡死 + +**责任方**:PR 作者 + +### 类型 C:精度回归 +信号:`Accuracy of ... is X, lower than Y`、精度跌幅 > 5% -根据失败发生的步骤进行初步分类: +**责任方**:PR 作者(需与算法团队确认) -| 失败步骤 | 初步定性 | 说明 | -|----------|---------|------| -| Set up job | 环境问题 | Runner 无法拉起/调度失败 | -| Initialize containers | 环境问题 | 容器运行时异常/镜像拉取失败 | -| Checkout / Setup Environment | 环境问题 | 网络/挂载/权限故障 | -| Run steps(pytest/lint 等) | 需进一步分析 | Runner 已就绪,进入 Step 2-3 细分 | +### 类型 D:YAML / 配置错误 +信号:`undefined variable "False"`、workflow 语法报错 -如果失败在前三类步骤,直接标记为环境问题并进入 Step 2。 -如果失败在 Run steps,需要同时执行 Step 2 和 Step 3 来区分环境故障与代码问题。 +**责任方**:PR 作者 / CI 维护者 -### Step 2: 物理节点溯源 + 环境故障深度定位 +### 类型 E:疑难 / 概率性问题 +信号:偶发挂死(如 triton ascend 概率挂)、无明确异常堆栈、重跑有时通过 -#### 2a. 物理节点溯源 -当判定为环境故障或怀疑硬件问题时,定位物理节点: +**常见错误模式速查**:`references/common-patterns.md` +**vllm-ascend 专项**:`references/vllm-ascend.md` -1. 从 `Set up job` 日志或 `gh api` 获取 `runner_name`(即 Pod 名称)。 -2. 从 `runner_name` 前缀或 Job Labels 识别节点池(如 `a3-0`)。 -3. 自动发现 namespace:`kubectl get pods --all-namespaces --no-headers | grep ` 提取 namespace。 -4. 反查物理机: - - Pod 未销毁:`kubectl get pod -n -o custom-columns=NODE:.spec.nodeName --no-headers` - - Pod 已销毁:在日志中搜索 `Successfully assigned to `。 +--- + +## Step 5:输出报告 + +直接在对话中输出诊断结果,格式如下(每个失败 Job 一节): + +``` +# CI 故障诊断报告 + +**Run**: [Workflow名称] #[Run ID] +**PR**: [仓库/PR号] ([分支名]) +**时间**: [开始] ~ [结束] + +--- + +## 故障一:[Job 名称] + +- **定性**: [环境问题 / 代码Bug / 精度回归 / 配置错误 / 疑难] +- **根因**: [一句话直接原因,如"K8s 容器运行时在测试阶段强制终止,exit code 255"] +- **关键标识**: `[最关键的一行错误,如 ERR99999 / AssertionError / exit code 255]` +- **责任方**: [基础设施团队 / PR 作者] +- **建议**: [重跑 / 修改 XX 文件 XX 行 / 上报运维 / 检查 Master 节点 XX] +- **节点**: [仅硬件故障时填写:Runner Pod名 → 物理节点名,其他类型省略] + +--- + +[其他 Job 以同样格式继续] +``` + +**输出精简原则**: +- 不粘贴大段原始日志,只引用最关键的一行错误标识 +- 不描述诊断过程(不写"我们检查了A,发现B"),直接给结论 +- 节点池、物理机等环境信息仅在硬件故障时填写,其他类型省略 +- 若同一 Run 中多个 Job 根因相同,可合并为一条说明 +- **不输出步骤分析表格,不输出汇总表格** + +--- + +## Step 6:等待人工确认 + +输出诊断结果后停止,等待确认: +- "确认" / "没问题" → 生成 Action Plan(仅类型 B/C/D) +- 提出修正 → 在对话中重新输出修订后的诊断结果;若修正揭示了漏判或误判的模式,同步更新 `references/` 中对应的参考文件,防止下次重犯 +- 基础设施问题 → 提供操作建议后结束,不执行代码修改 + +> **重要**:不要跳过 Step 6 等待确认,直接进入 Step 7 + +--- + +## Step 7:生成 Action Plan + +用户确认后,生成结构化 Action Plan JSON 文件供后续 AI 修复使用。 + +> **注意**:只有代码 Bug/配置错误/精度回归 需要生成 Action Plan,基础设施问题不需要。 + +### Step 7.1:检查是否需要生成 + +| 问题类型 | 是否生成 Action Plan | 说明 | +|---------|-------------------|------| +| 类型 B(代码Bug) | ✅ 是 | PR 引入的 Bug,需要修改代码 | +| 类型 C(精度回归) | ✅ 是 | 需要调整参数或回滚 | +| 类型 D(YAML/配置错误) | ✅ 是 | 需要修改配置 | +| 类型 A(环境/基础设施) | ❌ 否 | 基础设施问题,不涉及代码修改 | +| 类型 E(疑难) | ❌ 否 | 偶发问题,重跑尝试 | + +### Step 7.2:生成 JSON 文件 + +``` +输出文件:/reports/action-plan-.json +``` + +```json +{ + "run_id": "", + "pr": "#", + "jobs_to_fix": [ + { + "job_id": "", + "job_name": "", + "issue_type": "code_bug|config_error|accuracy_regression", + "root_cause": "<一句话描述>", + "fix_actions": [ + { + "type": "code_change", + "file": "<文件路径>", + "action": "modify|add|remove", + "location": "<文件:行号 或 函数名>", + "original": "<原内容(可选)>", + "replacement": "<新内容>", + "reason": "<为什么这么改>" + } + ], + "verification": { + "command": "<验证命令>", + "expected": "<预期结果>" + } + } + ] +} +``` -#### 2b. 环境故障深度定位 -检查以下故障模式: +### Step 7.3:验证 Action Plan -- **驱动/硬件失效**:`npu-smi info` 报错、`ERR99999`、`error code 507035`、`Device not found`。 -- **资源溢出 (OOM)**:系统级 `Killed` 信号、`Bus error`(SHM 不足)、`No space left on device`。 -- **多机连锁超时**:多机任务中某节点报 `Timeout`,优先识别并检查 Master 节点。Master 节点识别方法: - - 检查 `RANK_TABLE_FILE` 中 `rank_id=0` 对应的节点 - - 或在日志中搜索 `master_addr` / `MASTER_ADDR` 环境变量指向的节点 - - 确认 Master 节点是否有 `Unexpected Exit` 或驱动报错 +生成后,在对话中输出: +- Action Plan 已写入文件路径 +- 总结每个 fix_action(文件、修改内容) -### Step 3: 非环境问题判定 +用户确认后执行实际代码修改。 -如果 Step 2 未发现环境/硬件故障,检查以下非环境因素: +--- + +## 参考资料 + +- **常见错误模式速查**:`references/common-patterns.md`(网络超时/容器崩溃/UT卡死/triton挂/NPU硬件等) +- **vllm-ascend 专用**:`references/vllm-ascend.md`(Runner 类型、内部服务、workflow 触发逻辑) +- **分类判断详细逻辑**:`references/classification-guide.md` + +--- -- **YAML 语法错误**:如 `undefined variable "False"`(应为小写 `false`)。 -- **业务逻辑报错**:`AssertionError`、Python Traceback 指向业务源码、测试用例失败。 -- **依赖问题**:pip/npm 安装失败但非网络原因(版本冲突、包不存在)。 +## 常见误判与规避 -### Step 4: 输出诊断报告 +### 1. tiling 编译警告 ≠ 测试失败根因 -根据诊断结果选择输出模式: +**现象**:日志中大量 `Register tiling func failed`、`Get op tiling func failed` +**实际情况**:这是 CANN SDK 在编译 optiling 时的 DEBUG 输出,属于正常行为 +**规避**:grep 时排除 tiling 相关模式,优先捕获 `RuntimeError`、`AssertionError`、`exit code 255` -#### 情况 A:确认为基础设施/硬件故障 +### 2. 日志"淹没"问题 -严格按照以下格式输出: +**现象**:高volume日志(如 tiling DEBUG)掩盖低volume但真正的根因 +**规避**:先按错误类型/阶段分离日志输出,高优先级错误(OOM/AssertionError)先展示 + +### 3. exit code 255 = K8s 终止 + +**现象**:测试实际已失败,但日志最后是 `command terminated with exit code 255` +**实际情况**:exit code 255 通常是 K8s 强制终止,说明测试进程已崩溃 +**分析**:往前搜索 `RuntimeError`、`AssertionError` 找到真正失败原因 + +--- + +## 使用示例 + +### 示例 1:通过 GitHub Actions Run URL 诊断 ``` -# GitHub Action 故障诊断报告 +/github-action-diagnose https://github.com/my-org/my-repo/actions/runs/12345678901 +``` + +Skill 会自动提取 `owner/repo` 和 `run_id`,执行 `gh run view --log-failed`,进入完整诊断流程。 -## 1. 故障概览 -- **任务名称**: [Job Name] -- **Run ID**: [Run ID] -- **故障定性**: 环境问题 / 硬件故障 / 集群调度异常 -- **判定依据**: [驱动报错/调度异常/硬件死锁/OOM 等] +### 示例 2:通过 Run ID 诊断(在 repo 目录下) -## 2. 详细诊断 -- **失败步骤**: [Set up job / Initialize containers / Run steps 等] -- **报错原文**: [引用关键日志片段] -- **物理节点**: [Runner 名] -> [Pod] -> [物理机/节点池] -- **根因分析**: [深层原因说明] ``` +/github-action-diagnose 12345678901 +``` + +从当前目录推断 repo,然后执行诊断。 + +### 示例 3:直接粘贴日志内容 + +``` +/github-action-diagnose +2024-01-15T10:23:45.123Z [error] npu-smi info: ERR99999 Device not found +2024-01-15T10:23:45.456Z [error] error code 507035 +``` + +--- + +## 注意事项 + +- 本 skill 仅做故障**定性与根因分析**,不输出修复建议或操作手册 +- 所有只读操作(`gh` CLI、`kubectl` 查询、日志读取)直接执行,无需用户确认 +- 诊断报告中的物理节点信息依赖 `kubectl` 访问权限,无权限时跳过节点溯源步骤 +- 参考资料位于 `references/` 目录,包括常见错误模式、vllm-ascend 专项说明、Ascend NPU 故障排查手册 + +## 相关 Skills + +- [claude-bot](../claude-bot/claude.md) — GitHub Issues/PR 中的 AI 助手,可触发本诊断流程 + +--- + +## 更新日志 + +### v2.0.0 (2026-03-31) +- 重构诊断流程:扩展至 7 步(原 5 步),增加精度回归、代码 Bug、Action Plan 生成阶段 +- 新增责任方标注(基础设施团队 / PR 作者) +- 新增 `references/` 参考资料目录(common-patterns、vllm-ascend、classification-guide、ascend-troubleshooting) +- 输出格式由纯报告改为结构化 Action Plan JSON(代码问题时) +- 新增常见误判规避说明(tiling 警告、日志淹没、exit code 255) + +### v1.0.0 (2026-03-16) +- 初始版本:5 步诊断流程(输入识别 → 生命周期分诊 → 节点溯源 → 非环境判定 → 输出报告) +- 支持 URL / Run ID / 粘贴日志三种输入方式 +- 支持基础设施故障(情况A)和非基础设施问题(情况B)两种输出模式 + +--- + +## 作者 -#### 情况 B:环境正常,非基础设施问题 +- v1.0.0: @hdsong2 +- v2.0.0: @zhangyang -采用自然语言简要反馈: -1. **结论**:直接告知"未发现环境/基础设施故障"。 -2. **依据**:简述分析了哪些环节(调度、镜像拉取、NPU 挂载等均正常)。 +## 最后更新 -注意:两种情况均不输出修复建议或操作手册。 +2026-03-31 diff --git a/skills/infrastructure/github-action-diagnose/evals/evals.json b/skills/infrastructure/github-action-diagnose/evals/evals.json index 6f78850..1a4a35c 100644 --- a/skills/infrastructure/github-action-diagnose/evals/evals.json +++ b/skills/infrastructure/github-action-diagnose/evals/evals.json @@ -1,53 +1,17 @@ { - "skill_name": "github-action-diagnose", + "skill_name": "ci-diagnosis", "evals": [ { "id": 1, - "prompt": "https://github.com/tile-ai/tilelang-ascend/actions/runs/22718535502", - "expected_output": "识别为基础设施故障:镜像拉取失败(image pull failure),输出结构化诊断报告", - "files": [], - "assertions": [ - { - "name": "输出结构化报告(情况A格式)", - "description": "输出包含 '# GitHub Action 故障诊断报告' 标题,说明技能正确识别为基础设施故障并使用了规定格式" - }, - { - "name": "识别为镜像拉取失败", - "description": "输出中包含 '镜像' 或 'image pull' 或 'pull' 相关关键词,说明技能正确定性了故障类型" - }, - { - "name": "包含故障定性字段", - "description": "输出包含 '故障定性' 字段,符合规定的报告结构" - }, - { - "name": "不包含修复建议", - "description": "输出不包含 '建议' 或 '修复' 或 '解决方案' 等字样,符合技能只做诊断不提供修复建议的要求" - } - ] + "prompt": "我们的 PR CI 挂了,Run ID 是 23326177540,repo 是 vllm-project/vllm-ascend,帮我诊断一下哪里出问题了", + "expected_output": "诊断所有失败 Job,写入 reports/diagnosis-23326177540.md,对话输出简短摘要和汇总表格", + "files": [] }, { "id": 2, - "prompt": "https://github.com/sgl-project/sglang/actions/runs/22573924024/job/65388415192", - "expected_output": "识别为基础设施故障:网络问题,输出结构化诊断报告", - "files": [], - "assertions": [ - { - "name": "输出结构化报告(情况A格式)", - "description": "输出包含 '# GitHub Action 故障诊断报告' 标题,说明技能正确识别为基础设施故障并使用了规定格式" - }, - { - "name": "识别为网络问题", - "description": "输出中包含 '网络' 或 'network' 或 'timeout' 或 'connection' 相关关键词,说明技能正确定性了故障类型" - }, - { - "name": "包含故障定性字段", - "description": "输出包含 '故障定性' 字段,符合规定的报告结构" - }, - { - "name": "不包含修复建议", - "description": "输出不包含 '建议' 或 '修复' 或 '解决方案' 等字样,符合技能只做诊断不提供修复建议的要求" - } - ] + "prompt": "这个 Job 一直失败,帮我看下:https://github.com/vllm-project/vllm-ascend/actions/runs/23282406275/job/67875153370", + "expected_output": "只诊断这一个 Job(不拉取整个 Run),写入 reports/diagnosis-job-67875153370.md,对话输出简短摘要和汇总表格", + "files": [] } ] } diff --git a/skills/infrastructure/github-action-diagnose/references/ascend-troubleshooting.md b/skills/infrastructure/github-action-diagnose/references/ascend-troubleshooting.md index 60118bf..ee57dea 100644 --- a/skills/infrastructure/github-action-diagnose/references/ascend-troubleshooting.md +++ b/skills/infrastructure/github-action-diagnose/references/ascend-troubleshooting.md @@ -36,4 +36,3 @@ kubectl logs -n kube-system -l app=ascend-device-plugin Look for: - `get npu device count failed` - `npu device is unhealthy` - diff --git a/skills/infrastructure/github-action-diagnose/references/classification-guide.md b/skills/infrastructure/github-action-diagnose/references/classification-guide.md new file mode 100644 index 0000000..6991964 --- /dev/null +++ b/skills/infrastructure/github-action-diagnose/references/classification-guide.md @@ -0,0 +1,76 @@ +# 问题分类判断树 + +分类体系与 SKILL.md 保持一致:**类型 A**(基础设施)/ **类型 B**(代码Bug)/ **类型 C**(精度回归)/ **类型 D**(配置错误)/ **类型 E**(疑难) + +--- + +## 第一步:失败发生在哪个阶段? + +| 失败步骤 | 初步定性 | +|---------|---------| +| `Set up job` / `Initialize containers` | **类型 A** — 直接归基础设施,无需看日志 | +| `Checkout` / `Install dependencies` | **倾向类型 A**,但需确认是网络还是版本号写错(见场景 B) | +| `Stream logs` / `Upload logs` | **类型 A** — 测试本身可能已通过,是 Runner 与 GitHub 通信失败 | +| `Run test` / `Build` | **需同时排查 A 和 B**,不可只做其中之一 | + +--- + +## 第二步:是类型 A 还是类型 B?(Run test/Build 阶段失败时) + +**倾向类型 A(基础设施)的信号:** +- 多个 PR / 多个 Job 同时失败,且根因相同 +- 重跑后通过 +- 错误指向网络、存储、NPU 硬件(`ERR99999`)、OOM、exit code 255 +- `shm_broadcast` 超时 + `EngineDeadError`(Nightly 调度不稳定) +- 无 Python 异常堆栈,进程被外部终止 + +**倾向类型 B(代码 Bug)的信号:** +- 有明确 Python 异常堆栈,且指向 PR 新增/修改的代码 +- 重跑仍然失败(稳定复现) +- 其他 PR 没有同样的失败 +- PR diff 与失败路径直接对应 + +**两者都有信号时 → 先排查 A,再排查 B**(基础设施问题可以掩盖代码问题,反之不成立) + +--- + +## 第三步:细分类型 + +### 类型 C:精度回归 +- 有明确精度数值报错:`Accuracy of ... is X, lower than Y` +- 跌幅 < 3%:先重跑确认是否 flaky +- 跌幅 > 5%:PR 引入功能性回归,检查推理路径改动 + +### 类型 D:配置/YAML 错误 +- `undefined variable "False"`(YAML 大小写) +- workflow 语法报错(actionlint) +- 路径/权限配置错误 + +### 类型 E:疑难 / 概率性 +- 无明确异常堆栈,进程静默挂死 +- 重跑有时通过、有时挂 +- 常见:triton ascend 概率挂、CPU offload 死锁 + +--- + +## 常见混淆场景 + +**场景 A:`AssertionError` 是代码 Bug 还是精度回归?** +- `AssertionError: Test0:` / `model_utils.py` 断言 → **类型 C**,精度回归 +- `AssertionError` 指向功能逻辑(如形状不匹配、返回值错误)→ **类型 B**,代码 Bug + +**场景 B:依赖失败 — 网络问题还是版本号写错?** +- `No solution found` / `no version of xxx==Y.Y.Y` → **类型 A**,版本不存在于镜像源(或写错) +- `connection refused` / 超时 → **类型 A**,网络/基础设施 + +**场景 C:NPU function error — 硬件还是代码兼容性?** +- `error code 507xxx` / `ERR99999` → **类型 A**,硬件/驱动故障 +- `error code 107xxx`(如 107030)→ CANN runtime 参数非法,**不是硬件信号**;若发生在 `model init` 且 PR 是版本升级/main2main → **类型 B**(新版本 init 路径与 torch_npu 不兼容) + +**场景 D:`EngineDeadError` — 代码 Bug 还是环境?** +- 伴随 `shm_broadcast` 60s 超时连续出现,且为 Nightly 任务 → **类型 A**,资源调度不稳定 +- 启动阶段即崩溃,有明确 Python 堆栈 → **类型 B** + +**场景 E:exit code 255** +- 通常是 K8s 强制终止,本身不是根因 +- 往前找 `RuntimeError`、`AssertionError` 才是真正失败原因 diff --git a/skills/infrastructure/github-action-diagnose/references/common-patterns.md b/skills/infrastructure/github-action-diagnose/references/common-patterns.md new file mode 100644 index 0000000..ba1a8f8 --- /dev/null +++ b/skills/infrastructure/github-action-diagnose/references/common-patterns.md @@ -0,0 +1,271 @@ +# 常见 CI 错误模式速查(昇腾 NPU / ARC / K8s 环境) + +## 基础设施类 + +### NPU 硬件错误(⚠️ 注意 error code 范围) +``` +ERR99999 +error code 507035 +npu-smi info: device X not found +``` +- **定性**:类型 A,硬件层故障 +- **分析**:用 `npu-smi info` 确认 NPU 状态;查看是否是特定节点的问题 +- **处理**:标记该节点为 SchedulingDisabled,上报硬件团队 +- **⚠️ 易误判**:`NPU function error: aclrtMemcpy, error code is 107xxx`(如 107030)**不是硬件错误**,是 CANN runtime 参数非法;若发生在 `model init` 阶段且 PR 是版本升级/main2main → 定性为**类型 B**(代码兼容性),参见 classification-guide.md 场景 D + +### 容器启动超时(Initialize containers 阶段) +``` +[error] Executing the custom container implementation failed. +Please contact your self hosted runner administrator. +``` +日志在 `12:50:13` 开始执行 `k8s/index.js` 但 1 小时后无任何进展: +- **定性**:类型 A +- **可能原因**:镜像拉取超时(网络抖动/仓库限流)、NPU 资源争用(节点上 NPU 全被占用)、K8s 容器编排脚本卡死(API Server 响应慢) +- **溯源**:查 Runner Pod 状态,反查物理节点,确认镜像拉取是否有失败事件 + +### 网络代理绕过(Install dependencies 阶段) +``` +Connecting to github.com|20.205.243.166|:443... connected. +Unable to establish SSL connection. +Error: command terminated with exit code 4 +``` +``` +ERROR 618: jwt:expired. +Error: command terminated with exit code 8 +``` +- **定性**:类型 A,基础设施/网络 +- **根因**:安装脚本用 `wget` 直连 `github.com`,未经集群内代理(如 `gh-proxy.test.osinfra.cn`)转发,导致 SSL 失败 / JWT 签名 URL 过期 +- **触发位置**:通常在 `scripts/ci/npu/npu_ci_install_dependency.sh` +- **修复方案 A(推荐)**:将下载 URL 替换为代理地址: + ```bash + GH_PROXY="https://gh-proxy.test.osinfra.cn" + wget "${GH_PROXY}/https://github.com/..." + ``` +- **修复方案 B**:将依赖预缓存到内部 cache-service,彻底脱离 GitHub 实时依赖 + +### OOM / 内存不足 +``` +Bus error (核心已转储) +Killed +exit code -9 +``` +- **定性**:类型 A +- **Bus error**:通常是 SHM(共享内存)不足,多机/多卡训练时常见 +- **Killed / exit code -9**:系统 OOM Killer 终止进程 +- **处理**:检查 Pod 的内存 limit 配置;检查是否有其他 Pod 占用了节点内存 + +### 多机任务连锁超时 +``` +[Rank X] Timeout waiting for ... +``` +- **定性**:类型 A,但需找主节点(Master / Rank 0) +- **分析**:某 Rank 报 Timeout 不一定是该 Rank 的问题——优先检查 **Master 节点**日志,是否有 `Unexpected Exit` 或驱动报错 + +### Nightly 资源调度不稳定(Worker 进程挂死) +``` +No available shared memory broadcast block found in 60 seconds. +EngineDeadError: EngineCore encountered an issue. +``` +- **定性**:类型 A,基础设施 / 资源调度不稳定 +- **背景**:Nightly 多机任务中,Worker 进程被调度抢占或 NPU 资源争用导致卡死,`shm_broadcast` 60s 超时连续触发,最终 EngineCore 死亡。`EngineDeadError` 本身不是根因,是连锁结果 +- **判断依据**:`shm_broadcast` 超时日志连续出现(每 60s 一次);任务为 Nightly(非 PR CI);早期推理吞吐日志正常,说明模型加载成功 +- **与代码 Bug 的区分**:代码 Bug 通常在启动阶段或首次推理即失败,且有明确的 Python 异常堆栈;此模式为运行一段时间后才崩溃,且无代码层异常 +- **处理**:直接重跑;若持续复现,检查对应节点的 NPU 调度竞争情况 + +### Runner 调度失败(Set up job 阶段) +``` +Waiting for runner... (timeout) +No runners available +``` +- **定性**:类型 A +- **分析**:查看 ARC Scale Set 是否有 listener Pod 在运行;查看 Pending Pod 数量;检查节点是否有 `SchedulingDisabled` + +--- + +## 代码类错误 + +### UnboundLocalError(典型陷阱) +``` +UnboundLocalError: cannot access local variable 'xxx' where it is not associated with a value +``` +- **定性**:类型 B,代码 Bug +- **典型模式**:`try: import lib except ImportError: pass`,之后直接使用 `lib` 变量,在 except 路径中 `lib` 未被赋值 +- **修复模式**: + ```python + try: + import huggingface_hub + _HF_HUB_AVAILABLE = True + except ImportError: + _HF_HUB_AVAILABLE = False + + # 使用时: + if _HF_HUB_AVAILABLE: + try: + ... + except huggingface_hub.errors.LocalEntryNotFoundError: + ... + ``` + +### 精度回归 +``` +AssertionError: 0.515 not greater than or equal to 0.59 +Accuracy of /root/.cache/.../ModelName is 0.515, is lower than 0.59 +``` +``` +FAILED tests/e2e/singlecard/test_quantization.py::test_qwen3_w8a8_quant - AssertionError: Test0: +tests/e2e/model_utils.py:53: AssertionError +``` +- **定性**:类型 C,精度回归 +- **分析方向**:找 PR 中对推理路径的改动(量化/反量化逻辑、speculative decoding、token 采样、接受逻辑) +- **注意**:`AssertionError: TestN:` 来自 `model_utils.py` 的输出正确性断言,也是精度回归,不是功能性代码 Bug;跌幅 > 5-13% 通常是功能性回归,不是随机波动 + +### YAML 语法错误 +``` +undefined variable "False" +``` +- **定性**:类型 D +- **根因**:YAML 中 `True`/`False` 应为小写 `true`/`false` + +### 测试断言失败 +``` +AssertionError +FAIL: TestXxx +Expected: xxx, Got: yyy +``` +- **分析步骤**: + 1. 判断是测试写错(预期值不合理)还是被测逻辑错 + 2. 对比 PR diff 找到变更点 + 3. 重跑确认是否 flaky + +--- + +### ModelScope 网络不稳定(Run test 阶段) +``` +Downloading model from modelscope... +ConnectionError: ('Connection aborted.', RemoteDisconnected('...')) +urllib3.exceptions.ReadTimeoutError +HTTPSConnectionPool: Read timed out +``` +- **定性**:类型 A,网络/基础设施 +- **背景**:vllm-ascend 测试使用 ModelScope 下载模型(`VLLM_USE_MODELSCOPE=True`),模型缓存在 `/root/.cache/modelscope/`。网络抖动会导致下载中断,表现为 Run test 阶段报网络超时而非代码异常 +- **判断依据**:失败在 Run test 早期(模型加载前),错误为网络类而非 Python 异常;其他 Job / 其他时间重跑可通过 +- **处理**:直接重跑。如频繁出现,检查集群到 ModelScope CDN 的网络质量;考虑将常用模型预缓存到节点本地,避免每次实时下载 + +### UT 卡死 / 超时(主线合入脏代码) +``` +# 无明显报错,Job 运行 2-3 小时后超时取消 +Canceling since a higher priority waiting request ... exists +# 或超时被 K8s 强制终止 +command terminated with exit code 255 +``` +- **定性**:类型 B,代码 Bug(通常是主线合入了有问题的代码) +- **背景**:UT 正常运行时间约 30-60 分钟。如果 UT 运行超过 2 小时仍无进展,通常是某个测试死锁或无限等待,而非基础设施问题 +- **与基础设施超时的区分**:基础设施超时发生在 `Initialize containers` 阶段;UT 卡死发生在 `Run unit test` 阶段,且 Job 已正常启动并运行了较长时间 +- **排查步骤**: + 1. 查看 UT 日志的最后输出,定位卡在哪个测试文件/函数 + 2. 检查最近合入 main 的 commit,是否包含涉及锁、进程通信、分布式初始化的改动 + 3. 本地运行该测试用例(单独运行,加超时)复现 +- **处理**:定位并 revert 引入死锁的 commit,或修复相关代码 + +### CPU Offload 概率性挂死 +``` +# 无异常堆栈,进程在 Run e2e test 阶段静默挂起 +Error executing in Docker Container: 1 +Executing the custom container implementation failed. +``` +- **定性**:类型 E,疑难/概率性问题 +- **背景**:CPU offload 功能在特定配置下存在概率性死锁,进程不报错但不返回,被容器超时机制终止,表现为 exit code 1 +- **与真实测试失败的区分**: + - 概率挂:日志在测试中途截断,无 `FAILED` / `AssertionError` 等 pytest 汇总行 + - 真实失败:有明确的 `FAILED tests/xxx.py::test_xxx` + 异常堆栈 +- **处理**:先重跑 1-2 次确认概率性;能通过则上报 CPU offload 模块追踪死锁路径 + +### 依赖解析失败(Install 阶段) +``` +× No solution found when resolving dependencies: +╰─▶ Because there is no version of modelscope==1.35.1 and you require + modelscope==1.35.1, we can conclude that your requirements are unsatisfiable. +``` +``` +ERROR: Could not find a version that satisfies the requirement xxx +ERROR: No matching distribution found for xxx +``` +- **定性**:类型 A,依赖源配置问题 +- **背景**:依赖版本在 PyPI / 镜像源中不存在,导致依赖解析失败,后续步骤连锁失败 +- **⚠️ 常见误判**:依赖解析失败后会产生大量 "error"、"failed" 级联日志(如 `find: '/usr/lib/python3': No such file`),容易被误判为 Python 环境问题或镜像缺失 +- **关键诊断**:搜索 `unsatisfiable|No solution found|no version of|requirements are unsatisfiable` +- **处理**:检查版本号是否正确、依赖是否已发布到配置的镜像源 + +### vllm-ascend 安装失败(Install 阶段 build error) +``` +Installing build dependencies: finished with status 'error' +error: subprocess-exited-with-error +× installing build dependencies did not run successfully. exit code: 1 +ERROR: Failed to build 'file:///__w/vllm-ascend/vllm-ascend/vllm-empty' +``` +- **定性**:类型 A 或 B,需区分 +- **背景**:`Install vllm-project/vllm-ascend` 或 `Install vllm from source` 阶段,csrc 编译或 build dependency 安装失败 +- **⚠️ 常见误判**:同步出现的 apt `403 Forbidden`(针对 `jammy-backports`/`jammy-security`)是次要现象,不是根因,这两个仓库为可选安全更新源,403 不影响核心依赖安装 +- **区分根因**: + - 检查 PR 是否修改了 csrc/CMakeLists/setup.py → 可能是 PR 引入(类型 B) + - 与最近主线无关、其他 PR 同期也失败 → 可能是环境依赖版本问题(类型 A) +- **处理**:在对应硬件(310p/A3)环境本地复现;检查 build dependency 版本兼容性 + +### CANN tiling 编译警告(⚠️ 不是根因) +``` +[ERROR][xxx] Register tiling func failed, please check the class name. +[ERROR][xxx] Get op tiling func failed, please check the soc version %d +[ERROR][xxx] Do op tiling failed, cannot find soc version. +``` +- **定性**:编译阶段正常输出,**不是测试失败根因** +- **背景**:这些日志来自 CANN SDK 的 tiling 注册过程,属于 DEBUG 级别的编译输出,不代表运行时失败 +- **⚠️ 常见误判**:这些日志数量大(可达数百行),容易被 grep 脚本捕获后误认为是根因 +- **识别方法**: + - 时间戳:在 `Install vllm-ascend` 阶段(编译期)出现 + - 日志级别:DEBUG 或普通 ERROR,无 Traceback + - 真正的根因需要往后找 `RuntimeError`、`AssertionError`、`exit code 255` +- **处理**:忽略 tiling 警告,继续分析测试阶段的运行时错误 + +### Git dubious ownership(Install 阶段) +``` +git introspection failed: fatal: detected dubious ownership in repository at '/__w/xxx/xxx' +``` +- **定性**:类型 A,基础设施 — Git 安全检查阻止构建 +- **背景**:Git 仓库所有者与当前用户不匹配,通常发生在容器内运行 CI Job 时 +- **关键诊断**:搜索 `dubious ownership` +- **处理**:在 workflow 或容器启动脚本中添加 `git config --global --add safe.directory '*'` + +### Triton Ascend 概率性挂死 +``` +# 无异常堆栈,进程静默挂起 +# 或: +Traceback (most recent call last): + ... +RuntimeError: [Triton] ... +# 或 Job 超时,无有意义的错误信息 +``` +- **定性**:类型 E,疑难/概率性问题 +- **背景**:triton_ascend 在特定算子或配置下存在概率性挂死,表现为进程不报错但永远不返回,最终被超时机制终止 +- **判断依据**: + - 重跑有时通过、有时挂 + - 失败点随机(不固定在某个测试) + - 日志截断,无完整的 Python 异常堆栈 + - 涉及 triton_ascend 编译或 kernel 执行阶段 +- **处理**: + 1. 先重跑 1-2 次确认是否概率性(能通过则大概率是此类问题) + 2. 记录卡死时最后执行的算子/测试,上报 triton_ascend 团队 + 3. 短期可跳过该测试或降低并发度规避;长期需 triton_ascend 侧修复 + +--- + +## Runner Pod 命名规范 + +ARC/K8s 环境中 Runner Pod 名称的结构: +``` +linux-aarch64-a3-2-x51bm-runner-ksnwc + ↑架构 ↑节点池 ↑ ↑随机后缀 + a3-2 = 节点池编号 + x51bm = 物理机标识(有时) +``` + +常见节点池:`a3-0`、`a3-2`、`a3-4` 等,对应不同的 ARC Runner Scale Set 和物理机组。 diff --git a/skills/infrastructure/github-action-diagnose/references/vllm-ascend.md b/skills/infrastructure/github-action-diagnose/references/vllm-ascend.md new file mode 100644 index 0000000..1608b8c --- /dev/null +++ b/skills/infrastructure/github-action-diagnose/references/vllm-ascend.md @@ -0,0 +1,152 @@ +# vllm-ascend 仓库专用参考 + +仓库:`vllm-project/vllm-ascend` +这是 vLLM 在华为昇腾(Ascend)NPU 上的适配层,基于 ARC/K8s + GitHub Actions。 + +--- + +## Runner 类型与节点池 + +vllm-ascend 有两种 runner,溯源方式不同: + +### 静态 Runner(固定节点,长期运行) +| Runner 名 | 硬件 | 用途 | +|-----------|------|------| +| `linux-aarch64-a2b3-0` | Atlas 800 A2B3 | 轻量 job(trigger 解析、变更检测) | +| `linux-aarch64-a2b3-1` | Atlas 800 A2B3 | E2E singlecard 测试 | +| `linux-aarch64-a3-2` | Atlas 800 A3 | A3 单机测试 | +| `linux-aarch64-a3-4` | Atlas 800 A3 | A3 4 卡测试 | +| `linux-aarch64-310p-1` | Atlas 300I Pro (310P) | 310P 单卡测试 | +| `linux-aarch64-310p-4` | Atlas 300I Pro (310P) | 310P 4 卡测试 | +| `linux-amd64-cpu-8-hk` | x86 CPU(香港) | lint / pre-commit(无 NPU) | + +静态 runner 出问题:pod 不会销毁,可直接 `kubectl get pod` 查节点。 + +### ARC 动态 Runner(K8s 弹性扩容) +用于 nightly 等高并发场景,pod 名格式: +``` +linux-aarch64-a3-2-x51bm-runner-ksnwc + ↑架构 ↑节点池 ↑随机后缀 +``` +节点池编号(`a3-0`、`a3-2` 等)对应不同物理机组。 +动态 runner 任务完成后 pod 会销毁,需从日志系统(Loki/ELK)查调度记录。 + +--- + +## 内部服务 + +这些是集群内部地址,出现在 workflow 日志中属于正常配置,**如果访问失败则是基础设施问题**: + +| 服务 | 地址 | 用途 | +|------|------|------| +| PyPI 缓存 | `http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple` | 所有 `uv pip install` 走这里 | +| apt 镜像 | `cache-service.nginx-pypi-cache.svc.cluster.local:8081` | apt 源替换 | +| GitHub 代理 | `https://gh-proxy.test.osinfra.cn` | git clone / wget GitHub 资源 | +| Ascend PyPI | `https://mirrors.huaweicloud.com/ascend/repos/pypi` | Ascend 专用包(CANN 等) | + +**关键诊断规则:** +- `Install dependencies` 失败 + 报 `cache-service` 连接超时 → **基础设施/集群内网问题**,不是代码问题 +- `wget github.com` 直连失败 → 脚本未走 `gh-proxy` → **脚本配置问题**(代码/CI维护者) +- `git clone github.com` 失败 → 检查是否配置了 `url insteadOf gh-proxy` + +--- + +## 主要 Workflow 及触发条件 + +| Workflow | 触发 | Runner | 说明 | +|---------|------|--------|------| +| `pr_test_light.yaml` | 每个 PR 到 main/*-dev/releases/v* | a2b3-1 + amd64 | lint + e2e-singlecard-light | +| `pr_test_full.yaml` | PR 打 label 或手动触发 | a2b3-1 + a3-2/4 | 完整测试套件 | +| `schedule_nightly_test_a3.yaml` | 每天 23:45 北京时间 | a3 动态 runner | nightly 全量测试 | +| `_pre_commit.yml` | 被上述 workflow 调用 | `linux-amd64-cpu-8-hk` | lint:actionlint / markdownlint / mypy | + +**PR CI 触发变更检测逻辑(重要):** +PR CI 中有一个 `changes` job,用 `dorny/paths-filter` 判断哪些测试需要跑: +- 改了 `vllm_ascend/**`、`tests/e2e/**`、`CMakeLists.txt`、`setup.py` 等 → 触发 e2e 测试 +- 只改了 `tests/ut/**` → 只触发 unit test +- 只改了文档等 → 只跑 lint + +因此:**如果测试没有被触发,先检查变更文件是否命中了对应的 path filter**,不要误判为 CI 基础设施问题。 + +--- + +## 测试套件结构 + +测试用 `run_suite.py` 统一调度,套件定义在 `.github/workflows/scripts/config.yaml`: + +| 套件名 | 说明 | +|--------|------| +| `e2e-singlecard-light` | PR CI 默认跑的轻量单卡测试 | +| `e2e-singlecard` | 完整单卡测试 | +| `e2e-2card-light` | 2 卡轻量测试 | +| `e2e-multicard-2-cards` | 2 卡多卡测试 | +| `e2e-multicard-4-cards` | 4 卡多卡测试 | + +测试文件路径:`tests/e2e/singlecard/`、`tests/e2e/multicard/`、`tests/ut/` + +**定位具体失败用例:** +```bash +# 从 gh 日志中找到失败的具体 test 文件和函数名 +gh run view --job --log-failed --repo vllm-project/vllm-ascend | grep -E "FAILED|ERROR|assert" +``` + +--- + +## 环境与依赖特殊说明 + +### 包管理器:uv(不是 pip) +所有依赖安装用 `uv pip install`,报错格式与 pip 略有不同: +``` +# uv 典型报错 +error: Distribution ... not found (no matching distribution) +error: Failed to download ... Connection refused +``` + +### 模型来源:ModelScope(不是 HuggingFace) +所有测试环境设置了: +``` +VLLM_USE_MODELSCOPE: True +HF_HUB_OFFLINE: 1 +``` +模型缓存在 runner 节点的 `/root/.cache/modelscope/`(不是 `~/.cache/huggingface/`)。 +精度回归分析时,模型路径格式为: +``` +/root/.cache/modelscope/hub/xxx/ModelName +``` + +### CANN 环境激活 +Workflow 统一配置了: +```yaml +defaults: + run: + shell: bash -el {0} +``` +这是因为 CANN toolkit 的环境变量需要通过 shell profile 激活。如果某个 step 没有用这个 shell 配置,可能导致 NPU 驱动不可用。 + +--- + +## 常见 PR CI 失败场景速查 + +### Lint 失败(pre-commit job) +- **Runner**:`linux-amd64-cpu-8-hk`(CPU,无 NPU,不需要节点溯源) +- **常见错误**:mypy 类型检查失败、markdownlint 格式问题、actionlint workflow 语法问题 +- **责任方**:PR 作者 +- **修复**:本地运行 `pre-commit run --all-files` 复现 + +### Install dependencies 失败 +- 先判断是哪个依赖源出问题:`cache-service`(基础设施)还是 GitHub 直连(脚本配置) +- `uv pip install` 从 `cache-service` 失败 → 基础设施问题,重跑 +- `wget`/`git clone` 从 `github.com` 直连失败 → 检查 gh-proxy 配置 + +### 测试精度回归 +vllm-ascend 的精度测试会检查模型推理结果是否达到阈值,常见断言: +```python +assert accuracy >= threshold, f"Accuracy of {model_path} is {accuracy}, is lower than {threshold}" +``` +- 跌幅 < 3%:可能是随机性,先确认是否 flaky(重跑) +- 跌幅 > 5%:PR 引入了功能性回归,检查 speculative decoding、采样逻辑、kernel 改动 + +### 多机任务失败(nightly multi-node) +- nightly A3 的多机测试中,Rank N timeout 不代表 Rank N 出问题 +- 排查顺序:Master(Rank 0)→ 其他 Rank +- 检查 Master 的日志:`Unexpected Exit`、驱动报错、`ERR99999` diff --git a/skills/infrastructure/github-action-diagnose/scripts/collect-failed-runs.js b/skills/infrastructure/github-action-diagnose/scripts/collect-failed-runs.js new file mode 100644 index 0000000..f1755b6 --- /dev/null +++ b/skills/infrastructure/github-action-diagnose/scripts/collect-failed-runs.js @@ -0,0 +1,369 @@ +#!/usr/bin/env node +/** + * collect-failed-runs.js - 收集 GitHub 仓库一段时间内失败的 CI,支持按 Job 名称过滤 + * + * 用法: + * node scripts/collect-failed-runs.js --hours 24 --exclude-jobs lint + * node scripts/collect-failed-runs.js --from 2024-01-01 --to 2024-01-31 + * node scripts/collect-failed-runs.js --hours 24 --exclude-jobs lint "check-style" --output custom.xlsx + * + * 依赖: + * npm install xlsx + */ + +const XLSX = require('xlsx'); +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +function parseArgs() { + const args = process.argv.slice(2); + const parsed = { + repo: 'vllm-project/vllm-ascend', + from_date: null, + to_date: null, + hours: null, + exclude_jobs: [], + output: null, + token: process.env.GITHUB_TOKEN || '', + per_page: 100, + }; + + let i = 0; + while (i < args.length) { + switch (args[i]) { + case '--repo': + parsed.repo = args[++i]; + break; + case '--from': + parsed.from_date = args[++i]; + break; + case '--to': + parsed.to_date = args[++i]; + break; + case '--hours': + parsed.hours = parseInt(args[++i], 10); + break; + case '--exclude-jobs': + while (i + 1 < args.length && !args[i + 1].startsWith('--')) { + parsed.exclude_jobs.push(args[++i]); + } + break; + case '--output': + parsed.output = args[++i]; + break; + case '--token': + parsed.token = args[++i]; + break; + case '--per-page': + parsed.per_page = parseInt(args[++i], 10); + break; + default: + console.error(`未知参数: ${args[i]}`); + process.exit(1); + } + i++; + } + + return parsed; +} + +function getTimeRange(args) { + const now = new Date(); + + if (args.from_date && args.to_date) { + const since = new Date(args.from_date + 'T00:00:00Z'); + const until = new Date(args.to_date + 'T23:59:59Z'); + return { since: since.toISOString(), until: until.toISOString() }; + } else if (args.from_date) { + const since = new Date(args.from_date + 'T00:00:00Z'); + return { since: since.toISOString(), until: now.toISOString() }; + } else if (args.hours) { + const since = new Date(now.getTime() - args.hours * 60 * 60 * 1000); + return { since: since.toISOString(), until: now.toISOString() }; + } else { + const since = new Date(now.getTime() - 2 * 60 * 60 * 1000); + return { since: since.toISOString(), until: now.toISOString() }; + } +} + +function githubGet(urlPath, token) { + return new Promise((resolve, reject) => { + const url = new URL(urlPath, 'https://api.github.com'); + const options = { + hostname: url.hostname, + path: url.pathname + url.search, + method: 'GET', + headers: { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'collect-failed-runs-script', + }, + }; + + if (token) { + options.headers['Authorization'] = `Bearer ${token}`; + } + + https.get(options, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + resolve(JSON.parse(data)); + } catch (e) { + reject(new Error(`Failed to parse JSON: ${e.message}`)); + } + } else { + reject(new Error(`HTTP ${res.statusCode}: ${data}`)); + } + }); + }).on('error', reject); + }); +} + +async function fetchFailedRuns(repo, since, until, token, perPage) { + const allRuns = []; + let page = 1; + + while (true) { + const params = new URLSearchParams({ + status: 'failure', + per_page: perPage.toString(), + page: page.toString(), + created: `${since}..${until}`, + }); + + const urlPath = `/repos/${repo}/actions/runs?${params.toString()}`; + const data = await githubGet(urlPath, token); + const runs = data.workflow_runs || []; + + if (runs.length === 0) break; + + allRuns.push(...runs); + + if (runs.length < perPage) break; + page++; + } + + return allRuns; +} + +async function fetchRunJobs(repo, runId, token) { + const allJobs = []; + let page = 1; + + while (true) { + const params = new URLSearchParams({ + per_page: '100', + page: page.toString(), + }); + + const urlPath = `/repos/${repo}/actions/runs/${runId}/jobs?${params.toString()}`; + const data = await githubGet(urlPath, token); + const jobs = data.jobs || []; + + if (jobs.length === 0) break; + + allJobs.push(...jobs); + + if (jobs.length < 100) break; + page++; + } + + return allJobs; +} + +function shouldExcludeRun(jobs, excludePatterns) { + if (excludePatterns.length === 0) return false; + + const failedJobs = jobs.filter((j) => j.conclusion === 'failure'); + if (failedJobs.length === 0) return true; + + for (const job of failedJobs) { + const jobName = job.name || ''; + const isExcluded = excludePatterns.some((pattern) => + new RegExp(pattern, 'i').test(jobName) + ); + if (!isExcluded) return false; + } + + return true; +} + +function getExcludedJobNames(jobs, excludePatterns) { + const excluded = []; + for (const job of jobs) { + if (job.conclusion !== 'failure') continue; + const jobName = job.name || ''; + const isExcluded = excludePatterns.some((pattern) => + new RegExp(pattern, 'i').test(jobName) + ); + if (isExcluded) excluded.push(jobName); + } + return excluded; +} + +function calcDuration(isoStr1, isoStr2) { + try { + const d1 = new Date(isoStr1); + const d2 = new Date(isoStr2); + const diffMs = d2 - d1; + const totalSeconds = Math.floor(diffMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + parts.push(`${seconds}s`); + return parts.join(' '); + } catch { + return 'N/A'; + } +} + +function formatTime(isoStr) { + try { + const d = new Date(isoStr); + const pad = (n) => n.toString().padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; + } catch { + return isoStr; + } +} + +function writeXlsx(records, outputPath) { + const data = [ + ['URL', 'Run ID', '创建时间', '失败时间', '运行时间', '工作流名称', '分支', '用户'], + ]; + + for (const r of records) { + data.push([ + r.url, + r.run_id, + r.created_at, + r.updated_at, + r.duration, + r.workflow_name, + r.branch, + r.user, + ]); + } + + const ws = XLSX.utils.aoa_to_sheet(data); + + const colWidths = [ + { wch: 60 }, + { wch: 18 }, + { wch: 22 }, + { wch: 22 }, + { wch: 15 }, + { wch: 30 }, + { wch: 25 }, + { wch: 20 }, + ]; + ws['!cols'] = colWidths; + + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, '失败记录'); + XLSX.writeFile(wb, outputPath); +} + +async function main() { + const args = parseArgs(); + + if (!args.token) { + console.log('警告: 未设置 GitHub Token,可能触发 API 限流。'); + console.log('请设置环境变量 GITHUB_TOKEN 或使用 --token 参数。'); + } + + const { since, until } = getTimeRange(args); + console.log(`仓库: ${args.repo}`); + console.log(`时间范围: ${since} ~ ${until}`); + if (args.exclude_jobs.length > 0) { + console.log(`排除 Job: ${args.exclude_jobs.join(', ')}`); + } + console.log(); + + console.log('获取失败的 Runs...'); + const runs = await fetchFailedRuns( + args.repo, + since, + until, + args.token, + args.per_page + ); + console.log(`共获取到 ${runs.length} 个失败的 Run`); + + const records = []; + let skippedCount = 0; + const skippedReasons = []; + + for (let i = 0; i < runs.length; i++) { + const run = runs[i]; + const runId = run.id; + process.stdout.write(` 处理 [${i + 1}/${runs.length}] Run #${runId}... `); + + const jobs = await fetchRunJobs(args.repo, runId, args.token); + + if (shouldExcludeRun(jobs, args.exclude_jobs)) { + const excludedNames = getExcludedJobNames(jobs, args.exclude_jobs); + skippedCount++; + skippedReasons.push( + `Run #${runId} 被排除 (失败 Job: ${excludedNames.join(', ')})` + ); + console.log('跳过 (被过滤)'); + continue; + } + + const user = (run.triggering_actor && run.triggering_actor.login) || 'N/A'; + + records.push({ + url: `https://github.com/${args.repo}/actions/runs/${runId}`, + run_id: runId, + created_at: formatTime(run.created_at), + updated_at: formatTime(run.updated_at), + duration: calcDuration(run.created_at, run.updated_at), + workflow_name: run.name || 'N/A', + branch: run.head_branch || 'N/A', + user: user, + }); + console.log('保留'); + } + + console.log(); + console.log(`保留 ${records.length} 条记录,跳过 ${skippedCount} 条`); + + if (skippedReasons.length > 0) { + console.log('\n跳过的 Run:'); + for (const reason of skippedReasons) { + console.log(` - ${reason}`); + } + } + + if (records.length === 0) { + console.log('\n没有符合条件的记录,不生成文件。'); + return; + } + + let outputPath = args.output; + if (!outputPath) { + const outputDir = 'Fail_CI_Problem'; + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + const today = new Date().toISOString().split('T')[0]; + outputPath = path.join(outputDir, `failed-runs-${today}.xlsx`); + } + + writeXlsx(records, outputPath); + console.log(`\n已写入: ${outputPath}`); +} + +main().catch((err) => { + console.error('错误:', err.message); + process.exit(1); +}); diff --git a/skills/infrastructure/github-action-diagnose/scripts/diagnose-ci-runs.js b/skills/infrastructure/github-action-diagnose/scripts/diagnose-ci-runs.js new file mode 100644 index 0000000..a8b4c74 --- /dev/null +++ b/skills/infrastructure/github-action-diagnose/scripts/diagnose-ci-runs.js @@ -0,0 +1,495 @@ +#!/usr/bin/env node +/** + * diagnose-ci-runs.js - 从 xlsx 提取 CI URL,调用 fetch-run.sh 获取日志, + * 使用 Alibaba 大模型分析并写回诊断结果 + * + * 用法: + * node scripts/diagnose-ci-runs.js + * node scripts/diagnose-ci-runs.js --input Fail_CI_Problem/failed-runs-2026-04-10.xlsx + * node scripts/diagnose-ci-runs.js --input file.xlsx --model qwen3-coder-plus --batch-size 5 + * + * 依赖: + * npm install xlsx + * gh CLI 已安装并登录 + */ + +const XLSX = require('xlsx'); +const https = require('https'); +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +// ── 配置 ────────────────────────────────────────────────────────────────────── + +const ALIBABA_API_KEY = process.env.ALIBABA_API_KEY || ''; +const DEFAULT_MODEL = 'qwen3.6-plus'; +const DEFAULT_BATCH_SIZE = 5; +const DEFAULT_REPO = 'vllm-project/vllm-ascend'; + +// ── 参数解析 ────────────────────────────────────────────────────────────────── + +function parseArgs() { + const args = process.argv.slice(2); + const parsed = { + input: null, + model: DEFAULT_MODEL, + batchSize: DEFAULT_BATCH_SIZE, + limit: null, + repo: DEFAULT_REPO, + apiKey: ALIBABA_API_KEY, + }; + + let i = 0; + while (i < args.length) { + switch (args[i]) { + case '--input': + parsed.input = args[++i]; + break; + case '--model': + parsed.model = args[++i]; + break; + case '--batch-size': + parsed.batchSize = parseInt(args[++i], 10); + break; + case '--limit': + parsed.limit = parseInt(args[++i], 10); + break; + case '--repo': + parsed.repo = args[++i]; + break; + case '--api-key': + parsed.apiKey = args[++i]; + break; + default: + console.error(`未知参数: ${args[i]}`); + process.exit(1); + } + i++; + } + + return parsed; +} + +// ── 查找最新的 xlsx 文件 ───────────────────────────────────────────────────── + +function findLatestXlsx() { + const outputDir = path.join(__dirname, '..', 'Fail_CI_Problem'); + if (!fs.existsSync(outputDir)) { + console.error('错误: 未找到 Fail_CI_Problem 目录,请先运行 collect-failed-runs.js'); + process.exit(1); + } + + const files = fs.readdirSync(outputDir) + .filter(f => f.endsWith('.xlsx') && f.startsWith('failed-runs-')) + .sort() + .reverse(); + + if (files.length === 0) { + console.error('错误: Fail_CI_Problem 目录中没有找到 failed-runs-*.xlsx 文件'); + process.exit(1); + } + + return path.join(outputDir, files[0]); +} + +// ── 读取 xlsx ───────────────────────────────────────────────────────────────── + +function readXlsx(filePath) { + const wb = XLSX.readFile(filePath); + const ws = wb.Sheets[wb.SheetNames[0]]; + const data = XLSX.utils.sheet_to_json(ws); + return { wb, ws, data, filePath }; +} + +// ── 提取 run_id 从 URL ──────────────────────────────────────────────────────── + +function extractRunId(url) { + const match = url.match(/\/runs\/(\d+)/); + return match ? match[1] : null; +} + +// ── 使用 fetch-run.sh 获取日志(SKILL.md Step 1b 要求) ────────────────────── + +const SKILL_DIR = path.join(__dirname, '..', 'skills', 'infrastructure', 'github-action-diagnose'); +const FETCH_RUN_SCRIPT = path.join(SKILL_DIR, 'scripts', 'fetch-run.sh'); +const GIT_BASH = 'C:\\Program Files\\Git\\bin\\bash.exe'; + +function fetchRunLogs(runId, repo) { + try { + // 使用 fetch-run.sh 脚本获取日志(SKILL.md Step 1b 要求) + // 原因:1) 日志可能很大导致 API 超时 2) 脚本内置预过滤逻辑 3) 排除 tiling 警告 + const scriptPath = FETCH_RUN_SCRIPT.replace(/\\/g, '/'); + const output = execSync( + `"${GIT_BASH}" "${scriptPath}" ${runId} ${repo}`, + { encoding: 'utf8', timeout: 120000, maxBuffer: 50 * 1024 * 1024 } + ); + + // 如果脚本输出为空或太短,尝试直接获取 + if (!output || output.trim().length < 100) { + return fetchRunLogsFallback(runId, repo); + } + + return output; + } catch (err) { + console.error(` fetch-run.sh 失败: ${err.message}`); + console.log(' 使用备用方式获取日志...'); + return fetchRunLogsFallback(runId, repo); + } +} + +// 备用方案:直接使用 gh CLI 获取日志 +function fetchRunLogsFallback(runId, repo) { + try { + const runView = execSync( + `gh run view ${runId} --repo ${repo} 2>&1`, + { encoding: 'utf8', timeout: 60000, maxBuffer: 10 * 1024 * 1024 } + ); + + let failedLogs = ''; + try { + const logFailed = execSync( + `gh run view ${runId} --log-failed --repo ${repo} 2>&1`, + { encoding: 'utf8', timeout: 120000, maxBuffer: 50 * 1024 * 1024 } + ); + + const lines = logFailed.split('\n'); + const keyPatterns = [ + /RuntimeError|OutOfMemoryError|NPU out of memory|AssertionError|EngineDeadError/i, + /exit code [1-9]|exit code 255|Process completed with exit code/i, + /error code [0-9]+|ERR[0-9]+/i, + /ETIMEDOUT|Connection refused|Network is unreachable/i, + /OOM|Killed|Bus error|Segmentation fault/i, + /unsatisfiable|No solution found|dependency.*failed/i, + /Accuracy.*lower than|precision.*drop/i, + /undefined variable|syntax error|yaml.*error/i, + ]; + const excludePatterns = [ + /tiling func|Register tiling|ops_error\.h|error_check\.h/i, + /##\[group\]|##\[endgroup\]/i, + /^\s*$/, + ]; + + const keyLines = lines.filter(line => { + if (excludePatterns.some(p => p.test(line))) return false; + return keyPatterns.some(p => p.test(line)); + }).slice(0, 50); + + failedLogs = keyLines.join('\n'); + + if (keyLines.length < 10) { + const lastLines = lines.slice(-20).join('\n'); + failedLogs += '\n\n=== Last 20 lines of log ===\n' + lastLines; + } + } catch (e) { + // 忽略 + } + + return `=== Run Overview ===\n${runView}\n\n=== Failed Job Logs (filtered) ===\n${failedLogs}`; + } catch (err) { + console.error(` 获取 Run #${runId} 日志失败: ${err.message}`); + return null; + } +} + +// ── 调用 Alibaba API ────────────────────────────────────────────────────────── + +function callAlibabaAPI(messages, model, apiKey) { + return new Promise((resolve, reject) => { + const payload = JSON.stringify({ + model: model, + messages: messages, + temperature: 0.1, + max_tokens: 4096, + }); + + const options = { + hostname: 'dashscope.aliyuncs.com', + path: '/compatible-mode/v1/chat/completions', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + 'Content-Length': Buffer.byteLength(payload), + }, + }; + + const req = https.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + const json = JSON.parse(data); + const content = json.choices?.[0]?.message?.content || ''; + const usage = json.usage || {}; + resolve({ content, usage }); + } catch (e) { + reject(new Error(`Failed to parse response: ${e.message}`)); + } + } else { + reject(new Error(`HTTP ${res.statusCode}: ${data}`)); + } + }); + }); + + req.on('error', reject); + req.write(payload); + req.end(); + }); +} + +// ── 构建诊断 prompt ─────────────────────────────────────────────────────────── + +const REFERENCE_RULES = `## 诊断参考规则(精简版) + +### 分类判断树 +1. **失败阶段判断**: + - Set up job / Initialize containers → 类型 A(直接定性) + - Checkout / Install dependencies → 倾向 A,确认是网络还是版本号 + - Run test / Build → 同时排查 A 和 B + +2. **类型 A vs B 区分**: + - A 信号: 多 PR 同时失败、重跑通过、网络/硬件/OOM/exit 255、无 Python 堆栈 + - B 信号: 明确 Python 异常堆栈、稳定复现、与 PR diff 对应 + +### 常见错误模式速查 +- **NPU 硬件**: ERR99999 / error code 507xxx → 类型 A +- **NPU 兼容性**: error code 107xxx(如 107030)→ 不是硬件!CANN runtime 参数非法,PR 版本升级时 → 类型 B +- **容器启动**: "custom container implementation failed" → 类型 A(镜像拉取/资源争用/K8s 编排卡死) +- **OOM**: Bus error → SHM 不足;Killed / exit -9 → OOM Killer +- **多机超时**: 不要孤立分析报错节点,先找 Master/Rank 0 +- **Nightly 调度**: shm_broadcast 60s 超时 + EngineDeadError → 类型 A(资源调度不稳定,非代码问题) +- **依赖解析**: "No solution found" / "unsatisfiable" → 类型 A(版本不存在),注意不要误判为 Python 环境问题 +- **vllm-ascend 安装**: csrc 编译失败 → 检查 PR 是否改了 setup.py/CMakeLists(类型 B),否则环境依赖(类型 A) +- **依赖下载**: wget github.com 直连失败 → 脚本未走 gh-proxy → 脚本配置问题 +- **cache-service 超时**: 集群内网问题 → 类型 A +- **ModelScope 下载超时**: 网络抖动 → 类型 A,直接重跑 +- **UT 卡死**: 无明显报错但运行 2-3h 超时 → 类型 B(主线脏代码死锁) +- **CPU offload 挂死**: 日志截断无 FAILED 行 → 类型 E(概率性) +- **Triton 挂死**: 重跑随机通过/失败 → 类型 E + +### 精度回归判断 +- AssertionError: Test0: / model_utils.py → 类型 C(精度回归,非功能 Bug) +- 跌幅 < 3%: 先重跑确认是否 flaky +- 跌幅 > 5%: PR 引入功能性回归 + +### 易混淆场景 +- AssertionError 可能是精度回归(类型 C)或代码 Bug(类型 B),看来源 +- NPU function error 107xxx 不是硬件故障 +- EngineDeadError 伴随 shm_broadcast 超时 → 类型 A,启动阶段即崩溃 → 类型 B +- exit code 255 = K8s 强制终止,往前找真正根因 +- tiling 编译警告(Register tiling func failed)= 正常 DEBUG,忽略 + +### vllm-ascend 环境 +- 包管理器: uv(不是 pip) +- 模型来源: ModelScope(VLLM_USE_MODELSCOPE=True),路径 /root/.cache/modelscope/ +- CANN 环境需 shell -el 激活 +- Lint runner: linux-amd64-cpu-8-hk(CPU,无 NPU)`; + +function buildDiagnosisPrompt(runId, runInfo, logs) { + return `你是一个 CI 故障诊断专家,专门分析 GitHub Actions 在昇腾(Ascend)NPU 集群上的失败问题。 + +请根据以下 CI Run 的日志信息,进行根因分析并输出诊断报告。 + +## CI Run 信息 +- Run ID: ${runId} +- 工作流: ${runInfo.workflow_name || 'N/A'} +- 分支: ${runInfo.branch || 'N/A'} +- 创建时间: ${runInfo.created_at || 'N/A'} +- 失败时间: ${runInfo.updated_at || 'N/A'} +- 用户: ${runInfo.user || 'N/A'} + +## 日志信息 +\`\`\` +${logs} +\`\`\` + +${REFERENCE_RULES} + +## 输出要求 +请按照以下格式输出诊断结果(每个失败 Job 一节): + +\`\`\` +## 故障一:[Job 名称] +- **定性**: [环境问题 / 代码Bug / 精度回归 / 配置错误 / 疑难] +- **根因**: [一句话直接原因] +- **关键标识**: [最关键的一行错误] +- **责任方**: [基础设施团队 / PR 作者] +- **建议**: [重跑 / 修改 XX / 上报运维] +\`\`\` + +## 注意事项 +- 不粘贴大段原始日志,只引用最关键的一行错误 +- 不描述诊断过程,直接给结论 +- 使用上述参考规则进行精准分类 + +请输出诊断结果:`; +} + +// ── 主函数 ──────────────────────────────────────────────────────────────────── + +async function main() { + const args = parseArgs(); + + // 查找输入文件 + const inputPath = args.input || findLatestXlsx(); + if (!fs.existsSync(inputPath)) { + console.error(`错误: 文件不存在: ${inputPath}`); + process.exit(1); + } + + console.log(`输入文件: ${inputPath}`); + console.log(`模型: ${args.model}`); + console.log(`批量大小: ${args.batchSize}`); + console.log(); + + // 检查 API Key + if (!args.apiKey) { + console.error('错误: 未设置 Alibaba API Key'); + console.error('请设置环境变量 ALIBABA_API_KEY 或使用 --api-key 参数'); + process.exit(1); + } + + + + // 读取 xlsx + const { wb, ws, data, filePath } = readXlsx(inputPath); + + // 过滤出没有诊断结果的记录 + const recordsToDiagnose = data.filter(r => !r['诊断结果'] || r['诊断结果'].trim() === ''); + const alreadyDiagnosed = data.length - recordsToDiagnose.length; + + if (alreadyDiagnosed > 0) { + console.log(`跳过 ${alreadyDiagnosed} 条已诊断记录`); + } + + if (recordsToDiagnose.length === 0) { + console.log('所有记录已诊断,无需处理。'); + return; + } + + console.log(`待诊断记录: ${recordsToDiagnose.length} 条`); + if (args.limit) { + console.log(`限制诊断数量: ${args.limit} 条`); + } + console.log(); + + let diagnosedCount = 0; + let errorCount = 0; + let totalInputTokens = 0; + let totalOutputTokens = 0; + const maxRecords = args.limit || recordsToDiagnose.length; + + for (let i = 0; i < Math.min(recordsToDiagnose.length, maxRecords); i++) { + const record = recordsToDiagnose[i]; + const runId = extractRunId(record['URL'] || ''); + + if (!runId) { + console.log(`[${i + 1}/${recordsToDiagnose.length}] 跳过: 无法提取 Run ID`); + continue; + } + + console.log(`[${i + 1}/${recordsToDiagnose.length}] 诊断 Run #${runId}...`); + + // 获取日志 + console.log(' 获取日志...'); + const logs = fetchRunLogs(runId, args.repo); + + if (!logs || logs.trim().length === 0) { + console.log(' 日志为空,跳过'); + errorCount++; + continue; + } + + // 调用大模型 + console.log(' 调用大模型分析...'); + const prompt = buildDiagnosisPrompt(runId, record, logs); + + try { + const result = await callAlibabaAPI( + [{ role: 'user', content: prompt }], + args.model, + args.apiKey + ); + + // 更新记录 + record['诊断结果'] = result.content; + diagnosedCount++; + + // 统计 token + const inputTokens = result.usage?.prompt_tokens || 0; + const outputTokens = result.usage?.completion_tokens || 0; + totalInputTokens += inputTokens; + totalOutputTokens += outputTokens; + + console.log(` 诊断完成 (输入: ${inputTokens} tokens, 输出: ${outputTokens} tokens)`); + + // 每处理 batchSize 条保存一次 + if (diagnosedCount % args.batchSize === 0) { + console.log(` 保存进度 (${diagnosedCount} 条)...`); + saveProgress(wb, ws, data, filePath); + } + } catch (err) { + console.error(` 诊断失败: ${err.message}`); + record['诊断结果'] = `错误: ${err.message}`; + errorCount++; + } + + // 避免 API 限流,等待 1 秒 + if (i < recordsToDiagnose.length - 1) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + // 最终保存 + console.log('\n保存最终结果...'); + saveProgress(wb, ws, data, filePath); + + console.log(); + console.log(`诊断完成: ${diagnosedCount} 条成功, ${errorCount} 条失败`); + console.log(`结果已写入: ${filePath}`); + + // 输出 Token 统计 + const totalTokens = totalInputTokens + totalOutputTokens; + const avgInput = diagnosedCount > 0 ? Math.round(totalInputTokens / diagnosedCount) : 0; + const avgOutput = diagnosedCount > 0 ? Math.round(totalOutputTokens / diagnosedCount) : 0; + console.log(); + console.log('═══════════════════════════════════════════'); + console.log(' Token 消耗统计'); + console.log('═══════════════════════════════════════════'); + console.log(` 总输入 Token: ${totalInputTokens.toLocaleString()}`); + console.log(` 总输出 Token: ${totalOutputTokens.toLocaleString()}`); + console.log(` 总 Token: ${totalTokens.toLocaleString()}`); + console.log(` 平均输入/条: ${avgInput.toLocaleString()}`); + console.log(` 平均输出/条: ${avgOutput.toLocaleString()}`); + console.log(` 预估费用: ¥${(totalInputTokens / 1e6 * 10 + totalOutputTokens / 1e6 * 30).toFixed(2)}`); + console.log('═══════════════════════════════════════════'); +} + +function saveProgress(wb, ws, data, filePath) { + // 创建新的 workbook 来避免文件锁定问题 + const newWb = XLSX.utils.book_new(); + const newWs = XLSX.utils.json_to_sheet(data); + XLSX.utils.book_append_sheet(newWb, newWs, '失败记录'); + + // 设置列宽 + newWs['!cols'] = [ + { wch: 60 }, { wch: 18 }, { wch: 22 }, { wch: 22 }, + { wch: 15 }, { wch: 30 }, { wch: 25 }, { wch: 20 }, { wch: 80 } + ]; + + // 写入临时文件(使用 .xlsx 扩展名),然后替换 + const tempPath = filePath + '.temp.xlsx'; + XLSX.writeFile(newWb, tempPath); + + // 删除原文件并重命名临时文件 + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + fs.renameSync(tempPath, filePath); +} + +main().catch(err => { + console.error('错误:', err.message); + process.exit(1); +}); diff --git a/skills/infrastructure/github-action-diagnose/scripts/fetch-run.sh b/skills/infrastructure/github-action-diagnose/scripts/fetch-run.sh new file mode 100644 index 0000000..cbfcac7 --- /dev/null +++ b/skills/infrastructure/github-action-diagnose/scripts/fetch-run.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# scripts/fetch-run.sh [owner/repo] +# +# 用途:一次性抓取 CI Run 所有失败 Job 的 runner 信息和关键日志 +# 将每个 Job 原始日志(可达数百行)压缩至 ~20 行关键错误行 +# 在 LLM 进行 Step 2+ 推理之前完成所有数据收集,节省 token +# +# 用法: +# bash scripts/fetch-run.sh 22935910341 # Run ID +# bash scripts/fetch-run.sh 22935910341 owner/repo # Run ID + repo +# bash scripts/fetch-run.sh --job 67875153370 owner/repo # 单个 Job ID +# +# 输出: +# - Run 概览(Job 状态列表 + Annotations) +# - 每个失败 Job:runner 名称 + 预过滤的关键日志(~20 行) +# - cancelled Job:提示去看 Annotations(queue 抢占等无需读日志) +# +# 依赖:gh CLI(已登录) + +set -uo pipefail + +# 解析参数 +JOB_ID="" +RUN_ID="" +REPO="vllm-project/vllm-ascend" + +# 解析参数:支持 "--job [repo]" 或 " [repo]" +if [[ "$1" == "--job" ]]; then + JOB_ID="$2" + REPO="${3:-vllm-project/vllm-ascend}" +else + RUN_ID="${1:-}" + REPO="${2:-vllm-project/vllm-ascend}" +fi + +if [[ -n "$JOB_ID" ]]; then + # 单 Job 模式 + echo "════════════════════════════════════════════" + echo " Single Job Mode | $REPO" + echo "════════════════════════════════════════════" + + # ── Step A:基本信息(Runner / 时间 / 结论)────────────────────────────────── + JOB_META=$(gh api "repos/$REPO/actions/jobs/$JOB_ID" 2>/dev/null) + echo "$JOB_META" | \ + python3 -c " +import json,sys +d=json.load(sys.stdin) +print(f'── [{d[\"conclusion\"].upper()}] {d[\"name\"]}') +print(f' Runner : {d.get(\"runner_name\") or \"(unknown)\"}') +print(f' 时间 : {d.get(\"started_at\",\"?\")} ~ {d.get(\"completed_at\",\"?\")}') +steps=[s for s in d.get('steps',[]) if s.get('conclusion') not in ('success','skipped',None)] +if steps: + print(' ── 失败步骤') + for s in steps: + print(f' [{s[\"conclusion\"].upper():8}] Step {s[\"number\"]}: {s[\"name\"]}') +" + + # ── Step B:PR 上下文 ───────────────────────────────────────────────────────── + RUN_ID_FOR_JOB=$(echo "$JOB_META" | python3 -c "import json,sys; print(json.load(sys.stdin)['run_id'])" 2>/dev/null || echo "") + if [[ -n "$RUN_ID_FOR_JOB" ]]; then + RUN_META=$(gh api "repos/$REPO/actions/runs/$RUN_ID_FOR_JOB" 2>/dev/null) + PR_NUM=$(echo "$RUN_META" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['pull_requests'][0]['number'] if d.get('pull_requests') else '')" 2>/dev/null || echo "") + RUN_TITLE=$(echo "$RUN_META" | python3 -c "import json,sys; print(json.load(sys.stdin).get('display_title',''))" 2>/dev/null || echo "") + RUN_BRANCH=$(echo "$RUN_META" | python3 -c "import json,sys; print(json.load(sys.stdin).get('head_branch',''))" 2>/dev/null || echo "") + echo " PR : ${PR_NUM:+#$PR_NUM }${RUN_TITLE} (${RUN_BRANCH})" + if [[ -n "$PR_NUM" ]]; then + echo " ── PR 变更文件(分类参考)" + gh pr diff "$PR_NUM" --repo "$REPO" --name-only 2>/dev/null | head -20 | sed 's/^/ /' + fi + fi + + # ── Step C:Annotations(通常直接揭示根因,如 ETIMEDOUT / exit code)───────── + echo "" + echo " ── Annotations" + ANNOTATIONS=$(gh api "repos/$REPO/check-runs/$JOB_ID/annotations" 2>/dev/null) + ANN_COUNT=$(echo "$ANNOTATIONS" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0") + if [[ "$ANN_COUNT" -gt 0 ]]; then + echo "$ANNOTATIONS" | python3 -c " +import json,sys +for a in json.load(sys.stdin): + print(f' [{a.get(\"annotation_level\",\"?\").upper()}] {a.get(\"message\",\"\").splitlines()[0]}') +" + else + echo " (无 Annotations)" + fi + + # ── Step D:判断是否需要拉完整日志 ─────────────────────────────────────────── + # Annotations 中包含明确错误信号时,跳过日志拉取(节省时间) + ANN_TEXT=$(echo "$ANNOTATIONS" | python3 -c "import json,sys; print(' '.join(a.get('message','') for a in json.load(sys.stdin)))" 2>/dev/null || echo "") + NEEDS_LOG=true + if echo "$ANN_TEXT" | grep -qiE 'ETIMEDOUT|timeout|failed to create artifact|network|connection refused'; then + echo "" + echo " ── Annotations 已明确根因(网络/超时类),跳过完整日志拉取" + NEEDS_LOG=false + fi + + if $NEEDS_LOG; then + FULL_LOG=$(gh api "repos/$REPO/actions/jobs/$JOB_ID/logs" 2>&1) + + echo "" + echo " ── 运行时错误(高优先级)" + echo "$FULL_LOG" \ + | grep -iE 'RuntimeError|OutOfMemoryError|NPU out of memory|AssertionError|EngineDeadError|EngineCore encountered|shm_broadcast.*60 seconds|No available shared memory broadcast|Process completed with exit code [1-9]|exit code 255|synchronized memcpy failed|NPU function error|error code is [0-9]|httpcore\.(ReadError|ConnectError)|httpx\.(ReadError|ConnectError)|RemoteProtocolError' \ + | grep -v -E 'ops_error\.h|error_check\.h' \ + | head -15 + + RUNTIME_COUNT=$(echo "$FULL_LOG" | grep -cE 'RuntimeError|OutOfMemoryError|NPU out of memory|AssertionError|EngineDeadError|shm_broadcast.*60 seconds' || true) + if [[ "$RUNTIME_COUNT" -eq 0 ]]; then + echo " ── 编译/安装错误(次优先级)" + echo "$FULL_LOG" \ + | grep -iE 'error:.*failed|error:.*exit|fatal error|ERROR.*install|No solution found|unsatisfiable|ERROR.*Build|dubious ownership' \ + | grep -v -E '##\[group\]|##\[endgroup\]|warning.*format|ops_error\.h|error_check\.h|tiling func|tiling failed' \ + | head -5 + fi + + echo " ── 最后输出(定位失败阶段)" + echo "$FULL_LOG" \ + | grep -E '^[^[:space:]]+[[:space:]]+(UNKNOWN STEP|Run python|bash|pytest)' \ + | tail -5 + fi + + exit 0 +fi + +if [[ -z "$RUN_ID" ]]; then + echo "用法: fetch-run.sh [owner/repo]" + echo " 或: fetch-run.sh --job [owner/repo]" + exit 1 +fi + +SEP="════════════════════════════════════════════" + +# ── 1. Run 概览(Job 列表 + Annotations)──────────────────────────────────── +echo "$SEP" +echo " Run $RUN_ID | $REPO" +echo "$SEP" +gh run view "$RUN_ID" --repo "$REPO" +echo "" + +# ── PR 上下文(用于分类时区分代码 Bug 与基础设施)────────────────────────── +PR_NUM=$(gh api "repos/$REPO/actions/runs/$RUN_ID" \ + --jq '.pull_requests[0].number // empty' 2>/dev/null || echo "") +if [[ -n "$PR_NUM" ]]; then + echo "$SEP" + echo " PR #$PR_NUM 变更文件(分类参考)" + echo "$SEP" + gh pr diff "$PR_NUM" --repo "$REPO" --name-only 2>/dev/null | head -20 + echo "" +fi + +# ── 2. 获取失败 / 取消的 Job ID ────────────────────────────────────────────── +FAILED_IDS=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \ + --jq '.jobs[] | select(.conclusion == "failure" or .conclusion == "cancelled") | .id') + +if [[ -z "$FAILED_IDS" ]]; then + echo "(未发现失败或取消的 Job)" + exit 0 +fi + +TOTAL=$(echo "$FAILED_IDS" | wc -l | tr -d ' ') +echo "$SEP" +echo " 失败 / 取消 Job 详情(共 ${TOTAL} 个)" +echo "$SEP" + +# ── 3. 逐 Job 输出 runner 信息 + 关键日志 ──────────────────────────────────── +for JOB_ID in $FAILED_IDS; do + echo "" + + # Runner 信息(一次 API 调用,获取名称 / 结论 / 时间) + gh api "repos/$REPO/actions/jobs/$JOB_ID" \ + --jq '"── [\(.conclusion | ascii_upcase)] \(.name)\n Runner : \(.runner_name // "(unknown)")\n 时间 : \(.started_at // "?") ~ \(.completed_at // "?")"' + + CONCLUSION=$(gh api "repos/$REPO/actions/jobs/$JOB_ID" --jq '.conclusion') + + if [[ "$CONCLUSION" == "failure" ]]; then + FULL_LOG=$(gh run view --job "$JOB_ID" --log --repo "$REPO" 2>&1) + + echo " ── 运行时错误(高优先级)" + # 同时捕获 CANN 底层错误(aclrtMemcpy/error code),便于区分硬件 vs 代码兼容性 + echo "$FULL_LOG" \ + | grep -iE 'RuntimeError|OutOfMemoryError|NPU out of memory|AssertionError|EngineDeadError|EngineCore encountered|shm_broadcast.*60 seconds|No available shared memory broadcast|Process completed with exit code [1-9]|exit code 255|synchronized memcpy failed|NPU function error|error code is [0-9]|httpcore\.(ReadError|ConnectError)|httpx\.(ReadError|ConnectError)|RemoteProtocolError' \ + | grep -v -E 'ops_error\.h|error_check\.h' \ + | head -15 + + RUNTIME_COUNT=$(echo "$FULL_LOG" | grep -cE 'RuntimeError|OutOfMemoryError|NPU out of memory|AssertionError|EngineDeadError|shm_broadcast.*60 seconds' || true) + if [[ "$RUNTIME_COUNT" -eq 0 ]]; then + echo " ── 编译/安装错误(次优先级)" + echo "$FULL_LOG" \ + | grep -iE 'error:.*failed|error:.*exit|fatal error|ERROR.*install|No solution found|unsatisfiable|ERROR.*Build|dubious ownership' \ + | grep -v -E '##\[group\]|##\[endgroup\]|warning.*format|ops_error\.h|error_check\.h|tiling func|tiling failed' \ + | head -5 + fi + + # 显示 Job 最后阶段的关键标识 + echo " ── 最后输出(定位失败阶段)" + echo "$FULL_LOG" \ + | grep -E '^[^[:space:]]+[[:space:]]+(UNKNOWN STEP|Run python|bash|pytest)' \ + | tail -5 + + else + echo " (cancelled — 根因见 Run 概览中的 ANNOTATIONS,通常为 queue 抢占)" + fi +done + +echo "" +echo "$SEP" +echo " 补充命令(按需使用):" +echo " 完整日志 : gh run view --job --log --repo $REPO" +echo " PR diff : gh pr diff --repo $REPO --name-only" +echo " kubectl : kubectl get pods --all-namespaces | grep " +echo "$SEP"