已开启
feat: 添加 cann-review 技能 #1
GengChao创建于 3月19日
feat: 添加 cann-review 技能 #1
已开启
共 6 个文件变更+1546-1
| @@ -10,6 +10,9 @@ GitCode Skills 是一组用于支持 Claude Code 操作 GitCode 的技能包。 | |||
| 10 | ### gitcode-issue-skill | 10 | ### gitcode-issue-skill |
| 11 | 读取和分析 GitCode issues。支持使用 GitCode API 检索 issue 详情、评论,或分析 cann/ge 仓库中报告的问题。支持认证 API 访问和 markdown 格式化。 | 11 | 读取和分析 GitCode issues。支持使用 GitCode API 检索 issue 详情、评论,或分析 cann/ge 仓库中报告的问题。支持认证 API 访问和 markdown 格式化。 |
| 12 | 12 | ||
| 13 | +### cann-review-skill | ||
| 14 | +审查 GitCode Pull Request,专门用于 CANN 及相关仓库的代码检视。支持快速审查、深度代码审查、行内评论和自动+1。 | ||
| 15 | + | ||
| 13 | ## 安装与卸载 | 16 | ## 安装与卸载 |
| 14 | ### cluade code | 17 | ### cluade code |
| 15 | ### 安装 | 18 | ### 安装 |
| @@ -117,6 +120,19 @@ opencode: | |||
| 117 | gitcode-issue 读取https://gitcode.com/cann/ge/issues/153 | 120 | gitcode-issue 读取https://gitcode.com/cann/ge/issues/153 |
| 118 | ``` | 121 | ``` |
| 119 | 122 | ||
| 123 | +### 使用 cann-review | ||
| 124 | + | ||
| 125 | +当您需要审查 GitCode PR 时: | ||
| 126 | + | ||
| 127 | +```bash | ||
| 128 | +claude code: | ||
| 129 | +/gitcode-skills:cann-review 审查 https://gitcode.com/cann/metadef/pull/158 | ||
| 130 | + | ||
| 131 | +opencode: | ||
| 132 | +/skills | ||
| 133 | +cann-review 审查 https://gitcode.com/cann/metadef/pull/158 | ||
| 134 | +``` | ||
| 135 | + | ||
| 120 | ## 创建新gitcode技能 | 136 | ## 创建新gitcode技能 |
| 121 | 137 | ||
| 122 | 所有新技能必须使用随附的 **skill-creator** 创建: | 138 | 所有新技能必须使用随附的 **skill-creator** 创建: |
| @@ -168,7 +184,7 @@ gitcode-issue 读取https://gitcode.com/cann/ge/issues/153 | |||
| 168 | 路线图(欢迎贡献!): | 184 | 路线图(欢迎贡献!): |
| 169 | - [x] gitcode-issue -拉取issue | 185 | - [x] gitcode-issue -拉取issue |
| 170 | - [x] gitcode-pr - 提交pr,自动按格式填充pr描述 | 186 | - [x] gitcode-pr - 提交pr,自动按格式填充pr描述 |
| 171 | -- [ ] gitcode-review- - 提交检视意见,读取检视意见 | 187 | +- [x] cann-review - 审查pr,支持深度代码审查和行内评论 |
| 172 | - [ ] gitcode-ci - 触发ci流水线,获取结果,分析失败原因 | 188 | - [ ] gitcode-ci - 触发ci流水线,获取结果,分析失败原因 |
| 173 | 189 | ||
| 174 | ## 支持 | 190 | ## 支持 |
| @@ -0,0 +1,117 @@ | |||
| 1 | +# CANN Review Skill | ||
| 2 | + | ||
| 3 | +GitCode Pull Request 代码审查 Skill,用于 CANN 及相关仓库的 PR 审查。 | ||
| 4 | + | ||
| 5 | +## 功能 | ||
| 6 | + | ||
| 7 | +- 📊 **快速审查**: 自动获取 PR 元数据、文件变更、代码差异 | ||
| 8 | +- 🔍 **深度代码审查**: 逐行检查代码正确性、安全性、内存安全、代码风格 | ||
| 9 | +- 💬 **行内评论**: 支持在指定代码行添加评论 | ||
| 10 | +- ✅ **自动 LGTM**: 低风险 PR 可自动批准 | ||
| 11 | + | ||
| 12 | +## 安装 | ||
| 13 | + | ||
| 14 | +### 1. 复制 Skill 目录 | ||
| 15 | + | ||
| 16 | +将整个 `cann-review` 目录复制到你的 Claude skills 目录: | ||
| 17 | + | ||
| 18 | +```bash | ||
| 19 | +cp -r cann-review ~/.claude/skills/ | ||
| 20 | +``` | ||
| 21 | + | ||
| 22 | +### 2. 配置 GitCode API Token | ||
| 23 | + | ||
| 24 | +创建配置文件 `~/.cann-review/config`: | ||
| 25 | + | ||
| 26 | +```bash | ||
| 27 | +mkdir -p ~/.cann-review | ||
| 28 | +cat > ~/.cann-review/config << EOF | ||
| 29 | +GITCODE_TOKEN=your_token_here | ||
| 30 | +EOF | ||
| 31 | +``` | ||
| 32 | + | ||
| 33 | +或者设置环境变量: | ||
| 34 | + | ||
| 35 | +```bash | ||
| 36 | +export GITCODE_TOKEN=your_token_here | ||
| 37 | +``` | ||
| 38 | + | ||
| 39 | +## 依赖 | ||
| 40 | + | ||
| 41 | +- Python 3.7+ | ||
| 42 | +- 仅使用 Python 标准库,**无需安装任何第三方包** | ||
| 43 | + | ||
| 44 | +## 使用方法 | ||
| 45 | + | ||
| 46 | +### 快速审查 | ||
| 47 | + | ||
| 48 | +``` | ||
| 49 | +检视 https://gitcode.com/cann/ge/pull/1130 | ||
| 50 | +``` | ||
| 51 | + | ||
| 52 | +### 深度代码审查 | ||
| 53 | + | ||
| 54 | +``` | ||
| 55 | +详细检视代码 https://gitcode.com/cann/ge/pull/1130 | ||
| 56 | +``` | ||
| 57 | + | ||
| 58 | +### 直接 LGTM | ||
| 59 | + | ||
| 60 | +``` | ||
| 61 | +直接加 lgtm https://gitcode.com/cann/ge/pull/1130 | ||
| 62 | +``` | ||
| 63 | + | ||
| 64 | +## 代码审查维度 | ||
| 65 | + | ||
| 66 | +Skill 会对以下维度进行自动检查: | ||
| 67 | + | ||
| 68 | +| 维度 | 检查内容 | | ||
| 69 | +|------|----------| | ||
| 70 | +| **正确性** | 逻辑错误、边界检查、错误处理 | | ||
| 71 | +| **安全性** | 缓冲区溢出、注入攻击、输入验证 | | ||
| 72 | +| **内存安全** | 内存泄漏、双重释放、悬空指针 | | ||
| 73 | +| **代码风格** | 命名规范、代码重复、魔法数 | | ||
| 74 | +| **测试覆盖** | 单元测试、边界值、错误路径 | | ||
| 75 | +| **代码复用** | 抽象设计、DRY 原则 | | ||
| 76 | + | ||
| 77 | +## 自动问题检测 | ||
| 78 | + | ||
| 79 | +当审查代码发现以下问题时,Skill 会**主动询问**是否添加行内评论: | ||
| 80 | + | ||
| 81 | +- ⚠️ 内存泄漏 | ||
| 82 | +- ⚠️ 安全漏洞 | ||
| 83 | +- ⚠️ 使用过时 API | ||
| 84 | +- ⚠️ 关键逻辑错误 | ||
| 85 | +- ⚠️ 缺少输入验证 | ||
| 86 | +- ⚠️ 线程安全问题 | ||
| 87 | + | ||
| 88 | +## 目录结构 | ||
| 89 | + | ||
| 90 | +``` | ||
| 91 | +cann-review/ | ||
| 92 | +├── SKILL.md # Skill 定义文件 | ||
| 93 | +├── README.md # 本文件 | ||
| 94 | +├── agents/ | ||
| 95 | +│ └── openai.yaml # Agent 配置 | ||
| 96 | +└── scripts/ | ||
| 97 | + ├── gitcode_pr_review.py # PR 审查主脚本 | ||
| 98 | + └── gitcode_add_line_comment.py # 行内评论脚本 | ||
| 99 | +``` | ||
| 100 | + | ||
| 101 | +## 支持的 GitCode URL 格式 | ||
| 102 | + | ||
| 103 | +- `https://gitcode.com/cann/ge/pull/1130` | ||
| 104 | +- `https://gitcode.com/cann/ge/pulls/1130` | ||
| 105 | +- `https://gitcode.com/cann/ge/merge_request/1130` | ||
| 106 | +- `https://gitcode.com/cann/ge/merge_requests/1130` | ||
| 107 | + | ||
| 108 | +## 获取 GitCode API Token | ||
| 109 | + | ||
| 110 | +1. 访问 https://gitcode.com/ | ||
| 111 | +2. 登录后进入设置 | ||
| 112 | +3. 生成个人访问令牌 (Personal Access Token) | ||
| 113 | +4. 将 Token 配置到 `~/.cann-review/config` | ||
| 114 | + | ||
| 115 | +## 许可 | ||
| 116 | + | ||
| 117 | +本 Skill 仅供内部使用。 | ||
| @@ -0,0 +1,265 @@ | |||
| 1 | +--- | ||
| 2 | +name: cann-review | ||
| 3 | +description: Review GitCode pull requests for CANN and related repositories. Use when the user asks to review a GitCode PR, provides a GitCode PR URL, or mentions CANN PR review. Supports GitCode URL variants like /pull/<n>, /pulls/<n>, and /merge_requests/<n>, then fetches PR metadata and changed files for structured review. | ||
| 4 | +--- | ||
| 5 | + | ||
| 6 | +# CANN Review | ||
| 7 | + | ||
| 8 | +Use this skill when the user wants a review of a GitCode PR, especially for `cann/*` repositories. | ||
| 9 | + | ||
| 10 | +## Workflow | ||
| 11 | + | ||
| 12 | +### 1. 获取访问令牌(第一步必须) | ||
| 13 | + | ||
| 14 | +检查环境变量: | ||
| 15 | +```bash | ||
| 16 | +echo $GITCODE_TOKEN | ||
| 17 | +``` | ||
| 18 | + | ||
| 19 | +如果不存在,提示用户获取令牌: | ||
| 20 | +1. 登录 [GitCode](https://gitcode.com) | ||
| 21 | +2. 点击头像 → 设置 → 访问令牌 | ||
| 22 | +3. 创建新令牌,选择 `read_repository` 权限 | ||
| 23 | +4. 复制令牌,建议保存到 `~/.bashrc`:`export GITCODE_TOKEN="your_token_here"` | ||
| 24 | + | ||
| 25 | +### 2. Normalize the PR URL | ||
| 26 | + | ||
| 27 | +```bash | ||
| 28 | +python3 scripts/gitcode_pr_review.py "<pr_url>" | ||
| 29 | +``` | ||
| 30 | + | ||
| 31 | +### 3. Review Decision | ||
| 32 | + | ||
| 33 | +Default output is a quick review decision: | ||
| 34 | +- `可直接 LGTM` | ||
| 35 | +- `建议人工复查` | ||
| 36 | +- `不建议直接 LGTM` | ||
| 37 | + | ||
| 38 | +### 4. 如果 `GITCODE_TOKEN` 已设置,脚本还会获取: | ||
| 39 | +- PR title, state, author, branches, labels | ||
| 40 | +- changed files | ||
| 41 | +- patch excerpts, with size limits | ||
| 42 | + | ||
| 43 | +4. Accept these GitCode page URL kinds: | ||
| 44 | +- `/pull/<number>` | ||
| 45 | +- `/pulls/<number>` | ||
| 46 | +- `/merge_request/<number>` | ||
| 47 | +- `/merge_requests/<number>` | ||
| 48 | + | ||
| 49 | +5. Treat GitCode's API path as canonical: | ||
| 50 | +- page URLs may be `/pull/...` or `/merge_requests/...` | ||
| 51 | +- API URLs should use `/api/v5/repos/<project>/pulls/<number>` | ||
| 52 | + | ||
| 53 | +6. Review findings first. | ||
| 54 | +- Prioritize correctness issues, regressions, risky deletions, build/test impact, and missing validation. | ||
| 55 | +- Keep summaries brief after findings. | ||
| 56 | +- Do not claim file contents you did not fetch. | ||
| 57 | + | ||
| 58 | +7. Optional publishing actions require explicit flags. | ||
| 59 | +- `--post-comment` posts the generated review/report back to GitCode | ||
| 60 | +- `--auto-lgtm` only posts the literal `/lgtm` when the decision is `可直接 LGTM` | ||
| 61 | +- `--lgtm-only` posts the literal `/lgtm` immediately, without waiting for an automatic low-risk decision | ||
| 62 | + | ||
| 63 | +## Commands | ||
| 64 | + | ||
| 65 | +Parse only: | ||
| 66 | + | ||
| 67 | +```bash | ||
| 68 | +python3 scripts/gitcode_pr_review.py "https://gitcode.com/cann/metadef/pull/158" --skip-files | ||
| 69 | +``` | ||
| 70 | + | ||
| 71 | +Fetch quick review: | ||
| 72 | + | ||
| 73 | +```bash | ||
| 74 | +python3 scripts/gitcode_pr_review.py "https://gitcode.com/cann/metadef/pull/158" | ||
| 75 | +``` | ||
| 76 | + | ||
| 77 | +Fetch full report: | ||
| 78 | + | ||
| 79 | +```bash | ||
| 80 | +python3 scripts/gitcode_pr_review.py "https://gitcode.com/cann/metadef/pull/158" --format report | ||
| 81 | +``` | ||
| 82 | + | ||
| 83 | +Fetch raw context markdown: | ||
| 84 | + | ||
| 85 | +```bash | ||
| 86 | +python3 scripts/gitcode_pr_review.py "https://gitcode.com/cann/metadef/pull/158" --format context-markdown | ||
| 87 | +``` | ||
| 88 | + | ||
| 89 | +Write JSON context to a file: | ||
| 90 | + | ||
| 91 | +```bash | ||
| 92 | +python3 scripts/gitcode_pr_review.py "https://gitcode.com/cann/metadef/pull/158" --format json --output /tmp/pr_context.json | ||
| 93 | +``` | ||
| 94 | + | ||
| 95 | +Post review report to GitCode: | ||
| 96 | + | ||
| 97 | +```bash | ||
| 98 | +python3 scripts/gitcode_pr_review.py "https://gitcode.com/cann/metadef/pull/158" --format report --post-comment | ||
| 99 | +``` | ||
| 100 | + | ||
| 101 | +Post report and auto `/lgtm` when the change is low risk: | ||
| 102 | + | ||
| 103 | +```bash | ||
| 104 | +python3 scripts/gitcode_pr_review.py "https://gitcode.com/cann/metadef/pull/158" --format report --post-comment --auto-lgtm | ||
| 105 | +``` | ||
| 106 | + | ||
| 107 | +Post the literal `/lgtm` immediately: | ||
| 108 | + | ||
| 109 | +```bash | ||
| 110 | +python3 scripts/gitcode_pr_review.py "https://gitcode.com/cann/metadef/pull/158" --lgtm-only | ||
| 111 | +``` | ||
| 112 | + | ||
| 113 | +### Line Comment Commands | ||
| 114 | + | ||
| 115 | +Add a line comment to specific file and line: | ||
| 116 | + | ||
| 117 | +```bash | ||
| 118 | +python3 scripts/gitcode_add_line_comment.py \ | ||
| 119 | + "https://gitcode.com/cann/metadef/pull/159" \ | ||
| 120 | + --file "CMakeLists.txt" \ | ||
| 121 | + --line 128 \ | ||
| 122 | + --body "⚠️ **使用过时的CMake变量**: 建议改为现代写法" | ||
| 123 | +``` | ||
| 124 | + | ||
| 125 | +Dry-run to preview comment: | ||
| 126 | + | ||
| 127 | +```bash | ||
| 128 | +python3 scripts/gitcode_add_line_comment.py \ | ||
| 129 | + "https://gitcode.com/cann/metadef/pull/159" \ | ||
| 130 | + --file "CMakeLists.txt" \ | ||
| 131 | + --line 128 \ | ||
| 132 | + --body "Comment text" \ | ||
| 133 | + --dry-run | ||
| 134 | +``` | ||
| 135 | + | ||
| 136 | +Post as general PR comment (not line-specific): | ||
| 137 | + | ||
| 138 | +```bash | ||
| 139 | +python3 scripts/gitcode_add_line_comment.py \ | ||
| 140 | + "https://gitcode.com/cann/metadef/pull/159" \ | ||
| 141 | + --file "N/A" \ | ||
| 142 | + --line 1 \ | ||
| 143 | + --body "General comment" \ | ||
| 144 | + --general | ||
| 145 | +``` | ||
| 146 | + | ||
| 147 | +## Rules | ||
| 148 | + | ||
| 149 | +- If the user gives a GitCode PR URL, run the script first and use the default quick review unless raw context is explicitly needed. | ||
| 150 | +- If the user explicitly says "直接加 lgtm" or equivalent, use `--lgtm-only` so the posted content is the literal `/lgtm`, not plain `LGTM`. | ||
| 151 | +- If `GITCODE_TOKEN` is missing, explain that only URL parsing succeeded and remote PR data was not fetched. | ||
| 152 | +- For large PRs, rely on the summary plus selected patch excerpts; call out that the context is truncated. | ||
| 153 | +- Only post comments back to GitCode if the user explicitly asks for that action. | ||
| 154 | + | ||
| 155 | +## Deep Code Review Rules | ||
| 156 | + | ||
| 157 | +When user asks for detailed code review ("详细审查代码", "逐行检视", "代码检视"), perform comprehensive analysis: | ||
| 158 | + | ||
| 159 | +### 1. Fetch Full Context | ||
| 160 | +Always use `--format context-markdown` to get complete code diffs. | ||
| 161 | + | ||
| 162 | +### 2. Review Dimensions | ||
| 163 | + | ||
| 164 | +#### Correctness (正确性) | ||
| 165 | +- Check for logic errors, off-by-one errors, incorrect comparisons | ||
| 166 | +- Verify bug fixes actually fix the bug | ||
| 167 | +- Check for proper error handling and null pointer checks | ||
| 168 | + | ||
| 169 | +#### Security (安全性) | ||
| 170 | +- Buffer overflows, out-of-bounds access | ||
| 171 | +- SQL injection, command injection possibilities | ||
| 172 | +- Missing input validation | ||
| 173 | +- Use of unsafe functions (strcpy, sprintf, etc.) | ||
| 174 | +- Integer overflow/underflow | ||
| 175 | +- Uninitialized variables | ||
| 176 | + | ||
| 177 | +#### Memory Safety (内存安全) | ||
| 178 | +- Memory leaks (missing free/delete) | ||
| 179 | +- Double free, use-after-free | ||
| 180 | +- Dangling pointers | ||
| 181 | +- Smart pointer misuse | ||
| 182 | + | ||
| 183 | +#### Code Style (代码风格) | ||
| 184 | +- Naming conventions (snake_case for functions, PascalCase for classes in C++) | ||
| 185 | +- Code duplication | ||
| 186 | +- Magic numbers without constants | ||
| 187 | +- Inconsistent formatting | ||
| 188 | +- Overly complex functions (>100 lines, deep nesting) | ||
| 189 | + | ||
| 190 | +#### Testing Coverage (测试覆盖) | ||
| 191 | +- Are there unit tests for new functionality? | ||
| 192 | +- Edge cases covered (null, empty, boundary values)? | ||
| 193 | +- Error path testing? | ||
| 194 | +- Negative test cases? | ||
| 195 | + | ||
| 196 | +#### Code Reusability (代码复用性) | ||
| 197 | +- Extract common logic into shared functions? | ||
| 198 | +- Proper abstractions? | ||
| 199 | +- DRY principle violations? | ||
| 200 | + | ||
| 201 | +### 3. Severity Levels | ||
| 202 | + | ||
| 203 | +**Critical (阻塞)** - Must fix before merge: | ||
| 204 | +- Memory leaks | ||
| 205 | +- Security vulnerabilities | ||
| 206 | +- Crashes/assertion failures | ||
| 207 | +- Data corruption | ||
| 208 | + | ||
| 209 | +**High (严重)** - Should fix: | ||
| 210 | +- Logic errors | ||
| 211 | +- Missing error handling | ||
| 212 | +- Resource leaks | ||
| 213 | +- Thread safety issues | ||
| 214 | + | ||
| 215 | +**Medium (中等)** - Consider fixing: | ||
| 216 | +- Code duplication | ||
| 217 | +- Poor naming | ||
| 218 | +- Missing test coverage | ||
| 219 | +- Deprecated API usage | ||
| 220 | + | ||
| 221 | +**Low (轻微)** - Nice to have: | ||
| 222 | +- Style inconsistencies | ||
| 223 | +- Minor optimizations | ||
| 224 | +- Comment improvements | ||
| 225 | + | ||
| 226 | +### 4. Automatic Issue Detection | ||
| 227 | + | ||
| 228 | +When reviewing, if you detect any of the following, **proactively ask the user** if they want to add a line comment: | ||
| 229 | + | ||
| 230 | +``` | ||
| 231 | +"发现 [问题类型],是否添加行内评论到PR?" | ||
| 232 | +``` | ||
| 233 | + | ||
| 234 | +Trigger issues for automatic prompting: | ||
| 235 | +- ⚠️ Memory leaks | ||
| 236 | +- ⚠️ Security vulnerabilities (overflow, injection, etc.) | ||
| 237 | +- ⚠️ Use of deprecated APIs | ||
| 238 | +- ⚠️ Critical logic errors | ||
| 239 | +- ⚠️ Missing validation on user input | ||
| 240 | +- ⚠️ Thread safety issues | ||
| 241 | +- ⚠️ Code that will fail compilation on some platforms | ||
| 242 | + | ||
| 243 | +### 5. Interactive Comment Posting | ||
| 244 | + | ||
| 245 | +After presenting review findings, if the user confirms they want to add a comment: | ||
| 246 | + | ||
| 247 | +``` | ||
| 248 | +"已将审查意见作为行内评论添加到 [文件:行号]" | ||
| 249 | +``` | ||
| 250 | + | ||
| 251 | +Use the `gitcode_add_line_comment.py` script: | ||
| 252 | + | ||
| 253 | +```bash | ||
| 254 | +python3 scripts/gitcode_add_line_comment.py \ | ||
| 255 | + "<pr_url>" \ | ||
| 256 | + --file "<file_path>" \ | ||
| 257 | + --line <line_number> \ | ||
| 258 | + --body "<comment_body>" | ||
| 259 | +``` | ||
| 260 | + | ||
| 261 | +Comment body format: | ||
| 262 | +- Use ⚠️ emoji for warnings | ||
| 263 | +- Use ✅ for positives | ||
| 264 | +- Provide code examples in markdown code blocks | ||
| 265 | +- Keep it concise but actionable | ||
| @@ -0,0 +1,4 @@ | |||
| 1 | +interface: | ||
| 2 | + display_name: "CANN Review" | ||
| 3 | + short_description: "Review GitCode CANN PRs with robust URL parsing." | ||
| 4 | + default_prompt: "Review this GitCode PR. Normalize the URL first, fetch PR metadata and changed files, then report findings ordered by severity." | ||
| @@ -0,0 +1,223 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# -*- coding: utf-8 -*- | ||
| 3 | +""" | ||
| 4 | +GitCode PR Line Comment Poster | ||
| 5 | + | ||
| 6 | +Add line comments to GitCode pull requests. | ||
| 7 | +Supports both inline comments on specific lines and general PR comments. | ||
| 8 | +Uses only Python standard library - no external dependencies. | ||
| 9 | +""" | ||
| 10 | + | ||
| 11 | +import argparse | ||
| 12 | +import json | ||
| 13 | +import os | ||
| 14 | +import sys | ||
| 15 | +from urllib.parse import quote, urlparse | ||
| 16 | +from urllib.request import Request, urlopen | ||
| 17 | +from urllib.error import HTTPError, URLError | ||
| 18 | + | ||
| 19 | + | ||
| 20 | +def parse_pr_url(pr_url: str) -> dict: | ||
| 21 | + """Parse GitCode PR URL and extract components.""" | ||
| 22 | + import re | ||
| 23 | + | ||
| 24 | + PR_PATH_RE = re.compile( | ||
| 25 | + r"^/(?P<project>(?:[^/]+/){1,2}[^/]+)/" | ||
| 26 | + r"(?P<kind>pull|pulls|merge_request|merge_requests)/" | ||
| 27 | + r"(?P<number>[0-9]+)" | ||
| 28 | + r"/*$" | ||
| 29 | + ) | ||
| 30 | + | ||
| 31 | + parsed = urlparse(pr_url) | ||
| 32 | + if not parsed.netloc or "gitcode" not in parsed.netloc.lower(): | ||
| 33 | + raise ValueError("Not a GitCode URL") | ||
| 34 | + | ||
| 35 | + match = PR_PATH_RE.match(parsed.path.rstrip("/")) | ||
| 36 | + if not match: | ||
| 37 | + raise ValueError( | ||
| 38 | + "Unsupported GitCode PR URL path. Accepted path kinds: " | ||
| 39 | + "/pull/<n>, /pulls/<n>, /merge_request/<n>, /merge_requests/<n>" | ||
| 40 | + ) | ||
| 41 | + | ||
| 42 | + project_path = match.group("project").strip("/") | ||
| 43 | + project_parts = [part for part in project_path.split("/") if part] | ||
| 44 | + if len(project_parts) < 2: | ||
| 45 | + raise ValueError("GitCode project path must contain at least namespace/repo") | ||
| 46 | + | ||
| 47 | + number = int(match.group("number")) | ||
| 48 | + | ||
| 49 | + return { | ||
| 50 | + "project_path": project_path, | ||
| 51 | + "pr_number": number, | ||
| 52 | + "canonical_web_url": f"https://gitcode.com/{project_path}/merge_requests/{number}", | ||
| 53 | + "api_base": f"https://api.gitcode.com/api/v5/repos/{quote(project_path, safe='/')}", | ||
| 54 | + } | ||
| 55 | + | ||
| 56 | + | ||
| 57 | +def get_token() -> str: | ||
| 58 | + """Get GitCode API token from environment.""" | ||
| 59 | + token = os.environ.get("GITCODE_TOKEN") | ||
| 60 | + if not token: | ||
| 61 | + config_path = os.path.expanduser("~/.cann-review/config") | ||
| 62 | + if os.path.exists(config_path): | ||
| 63 | + with open(config_path, "r", encoding="utf-8") as f: | ||
| 64 | + for line in f: | ||
| 65 | + line = line.strip() | ||
| 66 | + if line.startswith("GITCODE_TOKEN="): | ||
| 67 | + token = line.split("=", 1)[1].strip().strip('"').strip("'") | ||
| 68 | + break | ||
| 69 | + if not token: | ||
| 70 | + raise ValueError( | ||
| 71 | + "GITCODE_TOKEN not set. Please export GITCODE_TOKEN environment variable." | ||
| 72 | + ) | ||
| 73 | + return token | ||
| 74 | + | ||
| 75 | + | ||
| 76 | +def api_get(url: str, headers: dict) -> dict: | ||
| 77 | + """Perform GET request to API.""" | ||
| 78 | + req = Request(url, headers=headers) | ||
| 79 | + try: | ||
| 80 | + with urlopen(req, timeout=30) as resp: | ||
| 81 | + return json.loads(resp.read().decode("utf-8")) | ||
| 82 | + except HTTPError as e: | ||
| 83 | + raise RuntimeError(f"HTTP {e.code}: {e.reason}") from e | ||
| 84 | + except URLError as e: | ||
| 85 | + raise RuntimeError(f"URL Error: {e.reason}") from e | ||
| 86 | + | ||
| 87 | + | ||
| 88 | +def api_post(url: str, headers: dict, data: dict) -> dict: | ||
| 89 | + """Perform POST request to API.""" | ||
| 90 | + body = json.dumps(data).encode("utf-8") | ||
| 91 | + req = Request(url, data=body, headers=headers, method="POST") | ||
| 92 | + try: | ||
| 93 | + with urlopen(req, timeout=30) as resp: | ||
| 94 | + response_data = resp.read().decode("utf-8") | ||
| 95 | + return {"status_code": resp.status, "success": resp.status in [200, 201], "response": response_data} | ||
| 96 | + except HTTPError as e: | ||
| 97 | + response_data = e.read().decode("utf-8") if e.fp else "" | ||
| 98 | + return {"status_code": e.code, "success": False, "response": response_data} | ||
| 99 | + except URLError as e: | ||
| 100 | + return {"status_code": 0, "success": False, "response": str(e.reason)} | ||
| 101 | + | ||
| 102 | + | ||
| 103 | +def get_pr_head_sha(api_base: str, pr_number: int, headers: dict) -> str: | ||
| 104 | + """Get the HEAD commit SHA of the PR.""" | ||
| 105 | + url = f"{api_base}/pulls/{pr_number}" | ||
| 106 | + data = api_get(url, headers) | ||
| 107 | + return data.get("head", {}).get("sha", "") | ||
| 108 | + | ||
| 109 | + | ||
| 110 | +def post_line_comment( | ||
| 111 | + api_base: str, | ||
| 112 | + pr_number: int, | ||
| 113 | + head_sha: str, | ||
| 114 | + file_path: str, | ||
| 115 | + line: int, | ||
| 116 | + body: str, | ||
| 117 | + headers: dict, | ||
| 118 | +) -> dict: | ||
| 119 | + """Post a line comment to a PR.""" | ||
| 120 | + url = f"{api_base}/pulls/{pr_number}/comments" | ||
| 121 | + | ||
| 122 | + payload = { | ||
| 123 | + "body": body, | ||
| 124 | + "commit_id": head_sha, | ||
| 125 | + "path": file_path, | ||
| 126 | + "position": line, | ||
| 127 | + } | ||
| 128 | + | ||
| 129 | + return api_post(url, headers, payload) | ||
| 130 | + | ||
| 131 | + | ||
| 132 | +def post_pr_comment( | ||
| 133 | + api_base: str, | ||
| 134 | + pr_number: int, | ||
| 135 | + body: str, | ||
| 136 | + headers: dict, | ||
| 137 | + reply_to_comment_id: str = None, | ||
| 138 | +) -> dict: | ||
| 139 | + """Post a general comment to a PR.""" | ||
| 140 | + url = f"{api_base}/pulls/{pr_number}/comments" | ||
| 141 | + | ||
| 142 | + payload = {"body": body} | ||
| 143 | + if reply_to_comment_id: | ||
| 144 | + # For replying to an existing comment (if supported) | ||
| 145 | + payload["in_reply_to"] = reply_to_comment_id | ||
| 146 | + | ||
| 147 | + return api_post(url, headers, payload) | ||
| 148 | + | ||
| 149 | + | ||
| 150 | +def main(): | ||
| 151 | + parser = argparse.ArgumentParser( | ||
| 152 | + description="Add line comments to GitCode pull requests" | ||
| 153 | + ) | ||
| 154 | + parser.add_argument("pr_url", help="GitCode PR URL") | ||
| 155 | + parser.add_argument("--file", required=True, help="File path to comment on") | ||
| 156 | + parser.add_argument("--line", type=int, required=True, help="Line number to comment on") | ||
| 157 | + parser.add_argument("--body", required=True, help="Comment body") | ||
| 158 | + parser.add_argument( | ||
| 159 | + "--general", | ||
| 160 | + action="store_true", | ||
| 161 | + help="Post as general PR comment instead of line comment", | ||
| 162 | + ) | ||
| 163 | + parser.add_argument( | ||
| 164 | + "--dry-run", | ||
| 165 | + action="store_true", | ||
| 166 | + help="Print what would be posted without actually posting", | ||
| 167 | + ) | ||
| 168 | + | ||
| 169 | + args = parser.parse_args() | ||
| 170 | + | ||
| 171 | + try: | ||
| 172 | + parsed = parse_pr_url(args.pr_url) | ||
| 173 | + token = get_token() | ||
| 174 | + headers = { | ||
| 175 | + "Authorization": f"Bearer {token}", | ||
| 176 | + "Content-Type": "application/json", | ||
| 177 | + } | ||
| 178 | + | ||
| 179 | + head_sha = get_pr_head_sha(parsed["api_base"], parsed["pr_number"], headers) | ||
| 180 | + | ||
| 181 | + if args.dry_run: | ||
| 182 | + print("[Dry Run] Would post the following comment:") | ||
| 183 | + print(f" PR: {parsed['canonical_web_url']}") | ||
| 184 | + print(f" File: {args.file}") | ||
| 185 | + print(f" Line: {args.line}") | ||
| 186 | + print(f" HEAD SHA: {head_sha}") | ||
| 187 | + print(f" Body:\n{args.body}") | ||
| 188 | + return 0 | ||
| 189 | + | ||
| 190 | + if args.general: | ||
| 191 | + result = post_pr_comment( | ||
| 192 | + parsed["api_base"], parsed["pr_number"], args.body, headers | ||
| 193 | + ) | ||
| 194 | + else: | ||
| 195 | + result = post_line_comment( | ||
| 196 | + parsed["api_base"], | ||
| 197 | + parsed["pr_number"], | ||
| 198 | + head_sha, | ||
| 199 | + args.file, | ||
| 200 | + args.line, | ||
| 201 | + args.body, | ||
| 202 | + headers, | ||
| 203 | + ) | ||
| 204 | + | ||
| 205 | + if result["success"]: | ||
| 206 | + print(f"✓ Comment posted successfully!") | ||
| 207 | + print(f" PR: {parsed['canonical_web_url']}") | ||
| 208 | + if not args.general: | ||
| 209 | + print(f" File: {args.file}:{args.line}") | ||
| 210 | + return 0 | ||
| 211 | + else: | ||
| 212 | + print(f"✗ Failed to post comment") | ||
| 213 | + print(f" Status: {result['status_code']}") | ||
| 214 | + print(f" Error: {result['response']}") | ||
| 215 | + return 1 | ||
| 216 | + | ||
| 217 | + except Exception as e: | ||
| 218 | + print(f"Error: {e}", file=sys.stderr) | ||
| 219 | + return 1 | ||
| 220 | + | ||
| 221 | + | ||
| 222 | +if __name__ == "__main__": | ||
| 223 | + sys.exit(main()) | ||
| @@ -0,0 +1,920 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import argparse | ||
| 6 | +import collections | ||
| 7 | +from datetime import datetime | ||
| 8 | +import json | ||
| 9 | +import os | ||
| 10 | +import re | ||
| 11 | +import sys | ||
| 12 | +from typing import Any | ||
| 13 | +from urllib.error import HTTPError, URLError | ||
| 14 | +from urllib.parse import quote, urlparse | ||
| 15 | +from urllib.request import Request, urlopen | ||
| 16 | + | ||
| 17 | + | ||
| 18 | +PR_PATH_RE = re.compile( | ||
| 19 | + r"^/(?P<project>.+?)/(?P<kind>pull|pulls|merge_request|merge_requests|merge-requests)/(?P<number>\d+)(?:/.*)?$" | ||
| 20 | +) | ||
| 21 | + | ||
| 22 | +SUPPORTED_KINDS = ("pull", "pulls", "merge_request", "merge_requests", "merge-requests") | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +class GitCodeApiError(RuntimeError): | ||
| 26 | + pass | ||
| 27 | + | ||
| 28 | + | ||
| 29 | +def read_token_from_config(config_path: str) -> str: | ||
| 30 | + try: | ||
| 31 | + with open(config_path, "r", encoding="utf-8") as handle: | ||
| 32 | + for raw_line in handle: | ||
| 33 | + line = raw_line.strip() | ||
| 34 | + if not line or line.startswith("#") or "=" not in line: | ||
| 35 | + continue | ||
| 36 | + key, value = line.split("=", 1) | ||
| 37 | + if key.strip() != "GITCODE_TOKEN": | ||
| 38 | + continue | ||
| 39 | + return value.strip().strip('"').strip("'") | ||
| 40 | + except OSError: | ||
| 41 | + return "" | ||
| 42 | + return "" | ||
| 43 | + | ||
| 44 | + | ||
| 45 | +def resolve_token(token_env: str) -> tuple[str, str]: | ||
| 46 | + env_token = os.environ.get(token_env, "").strip() | ||
| 47 | + if env_token: | ||
| 48 | + return env_token, f"env:{token_env}" | ||
| 49 | + | ||
| 50 | + script_dir = os.path.dirname(os.path.abspath(__file__)) | ||
| 51 | + candidate_paths = [ | ||
| 52 | + os.path.expanduser("~/.cann-review/config"), | ||
| 53 | + os.path.normpath(os.path.join(script_dir, "..", "config", "gitcode.conf")), | ||
| 54 | + ] | ||
| 55 | + | ||
| 56 | + seen: set[str] = set() | ||
| 57 | + for candidate in candidate_paths: | ||
| 58 | + normalized = os.path.abspath(candidate) | ||
| 59 | + if normalized in seen: | ||
| 60 | + continue | ||
| 61 | + seen.add(normalized) | ||
| 62 | + token = read_token_from_config(normalized) | ||
| 63 | + if token: | ||
| 64 | + return token, f"config:{normalized}" | ||
| 65 | + | ||
| 66 | + return "", "missing" | ||
| 67 | + | ||
| 68 | + | ||
| 69 | +def parse_pr_url(pr_url: str) -> dict[str, Any]: | ||
| 70 | + parsed = urlparse(pr_url.strip()) | ||
| 71 | + if parsed.scheme not in {"http", "https"}: | ||
| 72 | + raise ValueError("PR URL must start with http:// or https://") | ||
| 73 | + if parsed.netloc not in {"gitcode.com", "www.gitcode.com"}: | ||
| 74 | + raise ValueError("PR URL must point to gitcode.com") | ||
| 75 | + | ||
| 76 | + match = PR_PATH_RE.match(parsed.path.rstrip("/")) | ||
| 77 | + if not match: | ||
| 78 | + accepted = ", ".join(SUPPORTED_KINDS) | ||
| 79 | + raise ValueError( | ||
| 80 | + "Unsupported GitCode PR URL path. Accepted path kinds: " | ||
| 81 | + f"{accepted}. Example: https://gitcode.com/cann/metadef/pull/158" | ||
| 82 | + ) | ||
| 83 | + | ||
| 84 | + project_path = match.group("project").strip("/") | ||
| 85 | + project_parts = [part for part in project_path.split("/") if part] | ||
| 86 | + if len(project_parts) < 2: | ||
| 87 | + raise ValueError("GitCode project path must contain at least namespace/repo") | ||
| 88 | + | ||
| 89 | + number = int(match.group("number")) | ||
| 90 | + input_kind = match.group("kind") | ||
| 91 | + canonical_web_url = f"https://gitcode.com/{project_path}/merge_requests/{number}" | ||
| 92 | + api_url = f"https://api.gitcode.com/api/v5/repos/{quote(project_path, safe='/')}/pulls/{number}" | ||
| 93 | + | ||
| 94 | + return { | ||
| 95 | + "source_url": pr_url, | ||
| 96 | + "project_path": project_path, | ||
| 97 | + "namespace_path": "/".join(project_parts[:-1]), | ||
| 98 | + "repo_name": project_parts[-1], | ||
| 99 | + "pr_number": number, | ||
| 100 | + "input_kind": input_kind, | ||
| 101 | + "canonical_web_url": canonical_web_url, | ||
| 102 | + "api_url": api_url, | ||
| 103 | + "files_api_url": f"{api_url}/files", | ||
| 104 | + "comments_api_url": f"{api_url}/comments", | ||
| 105 | + } | ||
| 106 | + | ||
| 107 | + | ||
| 108 | +def request_json(url: str, token: str, timeout: float, method: str = "GET", data: Any = None) -> Any: | ||
| 109 | + headers = { | ||
| 110 | + "Accept": "application/json", | ||
| 111 | + "Authorization": f"Bearer {token}", | ||
| 112 | + } | ||
| 113 | + payload = None | ||
| 114 | + if data is not None: | ||
| 115 | + headers["Content-Type"] = "application/json" | ||
| 116 | + payload = json.dumps(data, ensure_ascii=False).encode("utf-8") | ||
| 117 | + request = Request(url, headers=headers, method=method, data=payload) | ||
| 118 | + try: | ||
| 119 | + with urlopen(request, timeout=timeout) as response: | ||
| 120 | + return json.loads(response.read().decode("utf-8")) | ||
| 121 | + except HTTPError as exc: | ||
| 122 | + body = exc.read().decode("utf-8", errors="replace") | ||
| 123 | + raise GitCodeApiError(f"GitCode API request failed: HTTP {exc.code} for {url}: {body}") from exc | ||
| 124 | + except URLError as exc: | ||
| 125 | + raise GitCodeApiError(f"GitCode API request failed for {url}: {exc}") from exc | ||
| 126 | + | ||
| 127 | + | ||
| 128 | +def fetch_json(url: str, token: str, timeout: float) -> Any: | ||
| 129 | + return request_json(url, token, timeout, method="GET") | ||
| 130 | + | ||
| 131 | + | ||
| 132 | +def post_comment(url: str, token: str, timeout: float, body: str) -> Any: | ||
| 133 | + return request_json(url, token, timeout, method="POST", data={"body": body}) | ||
| 134 | + | ||
| 135 | + | ||
| 136 | +def infer_file_status(file_item: dict[str, Any]) -> str: | ||
| 137 | + if file_item.get("status"): | ||
| 138 | + return str(file_item["status"]) | ||
| 139 | + if file_item.get("new_file"): | ||
| 140 | + return "added" | ||
| 141 | + if file_item.get("deleted_file"): | ||
| 142 | + return "deleted" | ||
| 143 | + if file_item.get("renamed_file"): | ||
| 144 | + return "renamed" | ||
| 145 | + return "modified" | ||
| 146 | + | ||
| 147 | + | ||
| 148 | +def extract_patch(file_item: dict[str, Any]) -> str: | ||
| 149 | + patch = file_item.get("patch") | ||
| 150 | + if isinstance(patch, dict): | ||
| 151 | + return str(patch.get("diff") or "") | ||
| 152 | + if isinstance(patch, str): | ||
| 153 | + return patch | ||
| 154 | + return "" | ||
| 155 | + | ||
| 156 | + | ||
| 157 | +def clip_text(text: str, limit: int) -> tuple[str, bool]: | ||
| 158 | + if limit <= 0: | ||
| 159 | + return "", bool(text) | ||
| 160 | + if len(text) <= limit: | ||
| 161 | + return text, False | ||
| 162 | + return text[:limit].rstrip() + "\n... [truncated]", True | ||
| 163 | + | ||
| 164 | + | ||
| 165 | +def summarize_pr(pr_data: dict[str, Any], files_data: list[dict[str, Any]]) -> dict[str, Any]: | ||
| 166 | + additions = sum(int(item.get("additions") or 0) for item in files_data) | ||
| 167 | + deletions = sum(int(item.get("deletions") or 0) for item in files_data) | ||
| 168 | + return { | ||
| 169 | + "title": pr_data.get("title"), | ||
| 170 | + "state": pr_data.get("state"), | ||
| 171 | + "author": (pr_data.get("user") or {}).get("login"), | ||
| 172 | + "created_at": pr_data.get("created_at"), | ||
| 173 | + "updated_at": pr_data.get("updated_at"), | ||
| 174 | + "draft": bool(pr_data.get("draft")), | ||
| 175 | + "mergeable": pr_data.get("mergeable"), | ||
| 176 | + "base_branch": ((pr_data.get("base") or {}).get("ref")), | ||
| 177 | + "head_branch": ((pr_data.get("head") or {}).get("ref")), | ||
| 178 | + "base_repo": (((pr_data.get("base") or {}).get("repo") or {}).get("full_name")), | ||
| 179 | + "head_repo": (((pr_data.get("head") or {}).get("repo") or {}).get("full_name")), | ||
| 180 | + "labels": [label.get("name") for label in pr_data.get("labels") or [] if label.get("name")], | ||
| 181 | + "files_changed": len(files_data), | ||
| 182 | + "additions": additions, | ||
| 183 | + "deletions": deletions, | ||
| 184 | + } | ||
| 185 | + | ||
| 186 | + | ||
| 187 | +def categorize_file(filename: str) -> set[str]: | ||
| 188 | + lower = filename.lower() | ||
| 189 | + base = os.path.basename(lower) | ||
| 190 | + categories: set[str] = set() | ||
| 191 | + | ||
| 192 | + if ( | ||
| 193 | + lower == "cmakelists.txt" | ||
| 194 | + or lower.endswith("/cmakelists.txt") | ||
| 195 | + or lower.endswith(".cmake") | ||
| 196 | + or lower.startswith("cmake/") | ||
| 197 | + or "/cmake/" in lower | ||
| 198 | + or lower.endswith(".mk") | ||
| 199 | + or base.startswith("build_") | ||
| 200 | + or (lower.endswith(".sh") and ("build" in base or "cmake" in base)) | ||
| 201 | + ): | ||
| 202 | + categories.add("build") | ||
| 203 | + | ||
| 204 | + if ( | ||
| 205 | + lower.startswith("tests/") | ||
| 206 | + or "/tests/" in lower | ||
| 207 | + or lower.startswith("test/") | ||
| 208 | + or "/test/" in lower | ||
| 209 | + or base.startswith("test_") | ||
| 210 | + or base.endswith("_test.cc") | ||
| 211 | + or base.endswith("_test.cpp") | ||
| 212 | + or base.endswith("_ut.cc") | ||
| 213 | + or base.endswith("_ut.cpp") | ||
| 214 | + ): | ||
| 215 | + categories.add("tests") | ||
| 216 | + | ||
| 217 | + if lower.endswith((".md", ".rst", ".adoc")): | ||
| 218 | + categories.add("docs") | ||
| 219 | + | ||
| 220 | + if lower.endswith((".yaml", ".yml", ".json", ".toml", ".ini", ".cfg", ".conf")): | ||
| 221 | + categories.add("config") | ||
| 222 | + | ||
| 223 | + if ".gitlab-ci" in lower or "jenkins" in lower or "workflow" in lower: | ||
| 224 | + categories.add("ci") | ||
| 225 | + | ||
| 226 | + if "/third_party/" in lower or lower.startswith("third_party/"): | ||
| 227 | + categories.add("third_party") | ||
| 228 | + | ||
| 229 | + if lower.endswith((".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".py", ".java", ".go", ".rs")): | ||
| 230 | + categories.add("source") | ||
| 231 | + | ||
| 232 | + if not categories: | ||
| 233 | + categories.add("other") | ||
| 234 | + | ||
| 235 | + return categories | ||
| 236 | + | ||
| 237 | + | ||
| 238 | +def extract_test_signal(pr_body: str, files_data: list[dict[str, Any]]) -> dict[str, Any]: | ||
| 239 | + has_test_files = any("tests" in categorize_file(str(item.get("filename") or "")) for item in files_data) | ||
| 240 | + body = (pr_body or "").strip() | ||
| 241 | + lower = body.lower() | ||
| 242 | + | ||
| 243 | + if not body: | ||
| 244 | + return {"status": "missing", "has_test_files": has_test_files} | ||
| 245 | + if re.search(r"(^|[\s::])na($|[\s,。,.\n])", lower) or "不涉及" in body or "无" in body: | ||
| 246 | + return {"status": "not_provided", "has_test_files": has_test_files} | ||
| 247 | + return {"status": "provided", "has_test_files": has_test_files} | ||
| 248 | + | ||
| 249 | + | ||
| 250 | +def build_review(pr_data: dict[str, Any], files_data: list[dict[str, Any]], summary: dict[str, Any]) -> dict[str, Any]: | ||
| 251 | + if not pr_data or not files_data: | ||
| 252 | + return { | ||
| 253 | + "decision": "insufficient_data", | ||
| 254 | + "decision_label": "信息不足", | ||
| 255 | + "severity": "unknown", | ||
| 256 | + "score": None, | ||
| 257 | + "summary": "未获取到完整文件变更,无法生成有效的快速检视结论。", | ||
| 258 | + "positives": [], | ||
| 259 | + "concerns": ["需要先获取 PR 文件列表和 diff。"], | ||
| 260 | + "next_actions": ["重新运行时不要使用 --skip-files,并确认可访问 GitCode API。"], | ||
| 261 | + "area_counts": {}, | ||
| 262 | + "status_counts": {}, | ||
| 263 | + "top_files": [], | ||
| 264 | + "test_signal": {"status": "missing", "has_test_files": False}, | ||
| 265 | + } | ||
| 266 | + | ||
| 267 | + area_counts: collections.Counter[str] = collections.Counter() | ||
| 268 | + status_counts: collections.Counter[str] = collections.Counter() | ||
| 269 | + deleted_files = 0 | ||
| 270 | + rename_only_files = 0 | ||
| 271 | + | ||
| 272 | + for item in files_data: | ||
| 273 | + filename = str(item.get("filename") or "") | ||
| 274 | + status = infer_file_status(item) | ||
| 275 | + status_counts[status] += 1 | ||
| 276 | + if status == "deleted": | ||
| 277 | + deleted_files += 1 | ||
| 278 | + if status == "renamed" and int(item.get("additions") or 0) == 0 and int(item.get("deletions") or 0) == 0: | ||
| 279 | + rename_only_files += 1 | ||
| 280 | + for category in categorize_file(filename): | ||
| 281 | + area_counts[category] += 1 | ||
| 282 | + | ||
| 283 | + top_files = sorted( | ||
| 284 | + ( | ||
| 285 | + { | ||
| 286 | + "filename": str(item.get("filename") or ""), | ||
| 287 | + "status": infer_file_status(item), | ||
| 288 | + "additions": int(item.get("additions") or 0), | ||
| 289 | + "deletions": int(item.get("deletions") or 0), | ||
| 290 | + } | ||
| 291 | + for item in files_data | ||
| 292 | + ), | ||
| 293 | + key=lambda item: (item["additions"] + item["deletions"], item["deletions"]), | ||
| 294 | + reverse=True, | ||
| 295 | + )[:5] | ||
| 296 | + | ||
| 297 | + test_signal = extract_test_signal(str(pr_data.get("body") or ""), files_data) | ||
| 298 | + touches_build = area_counts["build"] > 0 | ||
| 299 | + touches_source = area_counts["source"] > 0 | ||
| 300 | + docs_only = area_counts["docs"] == len(files_data) | ||
| 301 | + non_doc_files = len(files_data) - area_counts["docs"] | ||
| 302 | + config_only = (area_counts["docs"] + area_counts["config"]) == len(files_data) | ||
| 303 | + total_churn = int(summary["additions"]) + int(summary["deletions"]) | ||
| 304 | + | ||
| 305 | + score = 0 | ||
| 306 | + positives: list[str] = [] | ||
| 307 | + concerns: list[str] = [] | ||
| 308 | + next_actions: list[str] = [] | ||
| 309 | + | ||
| 310 | + if summary["files_changed"] > 30: | ||
| 311 | + score += 2 | ||
| 312 | + concerns.append(f"改动文件较多,共 {summary['files_changed']} 个文件,人工逐项确认成本高。") | ||
| 313 | + elif summary["files_changed"] > 10: | ||
| 314 | + score += 1 | ||
| 315 | + concerns.append(f"改动跨越 {summary['files_changed']} 个文件,已超出轻量改动范围。") | ||
| 316 | + | ||
| 317 | + if total_churn > 1000: | ||
| 318 | + score += 3 | ||
| 319 | + concerns.append(f"代码 churn 很大,新增/删除合计 {total_churn} 行。") | ||
| 320 | + elif total_churn > 200: | ||
| 321 | + score += 2 | ||
| 322 | + concerns.append(f"代码 churn 偏大,新增/删除合计 {total_churn} 行。") | ||
| 323 | + elif total_churn > 50: | ||
| 324 | + score += 1 | ||
| 325 | + | ||
| 326 | + if deleted_files > 10: | ||
| 327 | + score += 2 | ||
| 328 | + concerns.append(f"删除文件较多,共 {deleted_files} 个,需确认没有误删构建资产。") | ||
| 329 | + elif deleted_files > 0 and summary["deletions"] > 50: | ||
| 330 | + score += 1 | ||
| 331 | + concerns.append(f"存在 {deleted_files} 个删除文件,建议核对删除范围和依赖关系。") | ||
| 332 | + | ||
| 333 | + if touches_build: | ||
| 334 | + score += 2 | ||
| 335 | + concerns.append("触及 CMake/构建脚本/三方库编排,构建回归风险高于普通代码改动。") | ||
| 336 | + | ||
| 337 | + if area_counts["ci"] > 0: | ||
| 338 | + score += 2 | ||
| 339 | + concerns.append("触及 CI/流水线相关文件,需要额外验证流程是否仍然可运行。") | ||
| 340 | + | ||
| 341 | + if touches_source: | ||
| 342 | + score += 1 | ||
| 343 | + | ||
| 344 | + if test_signal["status"] != "provided" and (touches_build or touches_source): | ||
| 345 | + score += 1 | ||
| 346 | + concerns.append("PR 描述里没有给出有效测试步骤,但改动涉及构建或源码。") | ||
| 347 | + next_actions.append("补充可复现的构建/测试步骤,再决定是否 LGTM。") | ||
| 348 | + | ||
| 349 | + if test_signal["has_test_files"]: | ||
| 350 | + score -= 1 | ||
| 351 | + positives.append("PR 同时修改了测试相关文件。") | ||
| 352 | + | ||
| 353 | + if "ci-pipeline-passed" in summary["labels"]: | ||
| 354 | + score -= 1 | ||
| 355 | + positives.append("已有 `ci-pipeline-passed` 标签。") | ||
| 356 | + | ||
| 357 | + if docs_only: | ||
| 358 | + score -= 3 | ||
| 359 | + positives.append("改动仅限文档,风险较低。") | ||
| 360 | + elif config_only and not touches_build and not touches_source: | ||
| 361 | + score -= 2 | ||
| 362 | + positives.append("改动主要集中在文档/配置,未触及核心源码。") | ||
| 363 | + | ||
| 364 | + if rename_only_files == len(files_data) and total_churn == 0: | ||
| 365 | + score -= 2 | ||
| 366 | + positives.append("改动主要是纯重命名,没有实际 diff。") | ||
| 367 | + | ||
| 368 | + if summary["mergeable"] is True: | ||
| 369 | + positives.append("GitCode 标记为可合并。") | ||
| 370 | + | ||
| 371 | + if score <= 0: | ||
| 372 | + decision = "lgtm" | ||
| 373 | + decision_label = "可直接 LGTM" | ||
| 374 | + severity = "low" | ||
| 375 | + short_summary = "从改动规模和类型看属于低风险变更,可以直接给出 LGTM。" | ||
| 376 | + next_actions.append("可直接评论字面量 `/lgtm`,如有需要再补一句简短正向反馈。") | ||
| 377 | + elif score <= 3: | ||
| 378 | + decision = "manual_review" | ||
| 379 | + decision_label = "建议人工复查" | ||
| 380 | + severity = "medium" | ||
| 381 | + short_summary = "当前更像中等风险改动,建议在人工 spot-check 后再决定是否 LGTM。" | ||
| 382 | + next_actions.append("优先抽查 churn 最大的 3 个文件,再决定是否评论字面量 `/lgtm`。") | ||
| 383 | + else: | ||
| 384 | + decision = "do_not_lgtm" | ||
| 385 | + decision_label = "不建议直接 LGTM" | ||
| 386 | + severity = "high" | ||
| 387 | + short_summary = "改动规模或触达范围偏大,当前不适合在未进一步核对前直接 LGTM。" | ||
| 388 | + next_actions.append("先核对关键删除文件、CMake 路径调整和实际构建结果,再决定是否放行。") | ||
| 389 | + | ||
| 390 | + if not positives: | ||
| 391 | + positives.append("未发现明显的低风险信号。") | ||
| 392 | + | ||
| 393 | + top_areas = [name for name, _count in area_counts.most_common(3)] | ||
| 394 | + if top_areas: | ||
| 395 | + short_summary += " 主要影响区域:" + "、".join(top_areas) + "。" | ||
| 396 | + | ||
| 397 | + return { | ||
| 398 | + "decision": decision, | ||
| 399 | + "decision_label": decision_label, | ||
| 400 | + "severity": severity, | ||
| 401 | + "score": score, | ||
| 402 | + "summary": short_summary, | ||
| 403 | + "positives": positives, | ||
| 404 | + "concerns": concerns[:5], | ||
| 405 | + "next_actions": next_actions[:3], | ||
| 406 | + "area_counts": dict(area_counts), | ||
| 407 | + "status_counts": dict(status_counts), | ||
| 408 | + "top_files": top_files, | ||
| 409 | + "test_signal": test_signal, | ||
| 410 | + } | ||
| 411 | + | ||
| 412 | + | ||
| 413 | +def render_review_markdown(result: dict[str, Any]) -> str: | ||
| 414 | + parsed = result["parsed"] | ||
| 415 | + lines: list[str] = [] | ||
| 416 | + lines.append("# CANN PR 快速检视") | ||
| 417 | + lines.append("") | ||
| 418 | + | ||
| 419 | + if result["errors"]: | ||
| 420 | + lines.append("## 错误") | ||
| 421 | + for error in result["errors"]: | ||
| 422 | + lines.append(f"- {error}") | ||
| 423 | + lines.append("") | ||
| 424 | + return "\n".join(lines).rstrip() + "\n" | ||
| 425 | + | ||
| 426 | + review = result.get("review") or {} | ||
| 427 | + summary = result.get("summary") | ||
| 428 | + lines.append(f"## 结论") | ||
| 429 | + lines.append(f"- 判定: {review.get('decision_label', '信息不足')}") | ||
| 430 | + lines.append(f"- 严重性: {review.get('severity', 'unknown')}") | ||
| 431 | + if review.get("score") is not None: | ||
| 432 | + lines.append(f"- 风险分: {review['score']}") | ||
| 433 | + lines.append(f"- PR: {parsed['project_path']}#{parsed['pr_number']}") | ||
| 434 | + lines.append("") | ||
| 435 | + lines.append(review.get("summary") or "未生成摘要。") | ||
| 436 | + lines.append("") | ||
| 437 | + | ||
| 438 | + if summary: | ||
| 439 | + lines.append("## 概览") | ||
| 440 | + lines.append(f"- 标题: {summary['title']}") | ||
| 441 | + lines.append(f"- 作者: {summary['author']}") | ||
| 442 | + lines.append(f"- 分支: {summary['head_branch']} -> {summary['base_branch']}") | ||
| 443 | + lines.append(f"- 文件数: {summary['files_changed']}") | ||
| 444 | + lines.append(f"- 增删行: +{summary['additions']} / -{summary['deletions']}") | ||
| 445 | + lines.append(f"- 标签: {', '.join(summary['labels']) if summary['labels'] else '(none)'}") | ||
| 446 | + lines.append("") | ||
| 447 | + | ||
| 448 | + if review.get("concerns"): | ||
| 449 | + lines.append("## 主要关注点") | ||
| 450 | + for item in review["concerns"]: | ||
| 451 | + lines.append(f"- {item}") | ||
| 452 | + lines.append("") | ||
| 453 | + | ||
| 454 | + if review.get("positives"): | ||
| 455 | + lines.append("## 正向信号") | ||
| 456 | + for item in review["positives"]: | ||
| 457 | + lines.append(f"- {item}") | ||
| 458 | + lines.append("") | ||
| 459 | + | ||
| 460 | + if review.get("top_files"): | ||
| 461 | + lines.append("## 重点文件") | ||
| 462 | + for item in review["top_files"][:3]: | ||
| 463 | + lines.append( | ||
| 464 | + f"- {item['filename']} ({item['status']}, +{item['additions']} / -{item['deletions']})" | ||
| 465 | + ) | ||
| 466 | + lines.append("") | ||
| 467 | + | ||
| 468 | + if review.get("next_actions"): | ||
| 469 | + lines.append("## 建议动作") | ||
| 470 | + for item in review["next_actions"]: | ||
| 471 | + lines.append(f"- {item}") | ||
| 472 | + lines.append("") | ||
| 473 | + | ||
| 474 | + if result["notes"]: | ||
| 475 | + lines.append("## 备注") | ||
| 476 | + for note in result["notes"]: | ||
| 477 | + lines.append(f"- {note}") | ||
| 478 | + lines.append("") | ||
| 479 | + | ||
| 480 | + actions = result.get("actions") or {} | ||
| 481 | + action_lines: list[str] = [] | ||
| 482 | + if actions.get("comment_posted"): | ||
| 483 | + action_lines.append(f"已发布审查评论,comment_id={actions.get('comment_id')}") | ||
| 484 | + if actions.get("literal_lgtm_posted"): | ||
| 485 | + action_lines.append( | ||
| 486 | + f"已按显式请求发布字面量 `/lgtm`,comment_id={actions.get('literal_lgtm_comment_id')}" | ||
| 487 | + ) | ||
| 488 | + if actions.get("lgtm_posted"): | ||
| 489 | + action_lines.append(f"已发布字面量 `/lgtm`,comment_id={actions.get('lgtm_comment_id')}") | ||
| 490 | + for item in actions.get("skipped") or []: | ||
| 491 | + action_lines.append(item) | ||
| 492 | + if action_lines: | ||
| 493 | + lines.append("## 执行结果") | ||
| 494 | + for item in action_lines: | ||
| 495 | + lines.append(f"- {item}") | ||
| 496 | + lines.append("") | ||
| 497 | + | ||
| 498 | + return "\n".join(lines).rstrip() + "\n" | ||
| 499 | + | ||
| 500 | + | ||
| 501 | +def render_report_markdown(result: dict[str, Any]) -> str: | ||
| 502 | + parsed = result["parsed"] | ||
| 503 | + review = result.get("review") or {} | ||
| 504 | + summary = result.get("summary") or {} | ||
| 505 | + now_text = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %z") | ||
| 506 | + severity = str(review.get("severity") or "unknown").lower() | ||
| 507 | + severity_label = { | ||
| 508 | + "low": "✅ Low", | ||
| 509 | + "medium": "⚠️ Medium", | ||
| 510 | + "high": "❌ High", | ||
| 511 | + "critical": "🔴 Critical", | ||
| 512 | + }.get(severity, "❓ Unknown") | ||
| 513 | + conclusion = { | ||
| 514 | + "lgtm": "✅ 建议合入", | ||
| 515 | + "manual_review": "⚠️ 建议人工复查后再决定", | ||
| 516 | + "do_not_lgtm": "❌ 当前不建议直接 LGTM", | ||
| 517 | + "insufficient_data": "⚠️ 信息不足,无法得出可靠结论", | ||
| 518 | + }.get(review.get("decision"), "⚠️ 信息不足") | ||
| 519 | + test_signal = (review.get("test_signal") or {}).get("status", "missing") | ||
| 520 | + test_label = { | ||
| 521 | + "provided": "已提供", | ||
| 522 | + "not_provided": "未提供有效测试步骤", | ||
| 523 | + "missing": "未提供", | ||
| 524 | + }.get(test_signal, "未知") | ||
| 525 | + top_files = review.get("top_files") or [] | ||
| 526 | + top_areas = ", ".join((review.get("area_counts") or {}).keys()) or "(none)" | ||
| 527 | + limitations = [] | ||
| 528 | + files_meta = result.get("files_meta") or {} | ||
| 529 | + if files_meta.get("omitted_files", 0) > 0: | ||
| 530 | + limitations.append(f"仅展开了部分文件 patch,仍有 {files_meta['omitted_files']} 个文件未在报告中逐条展开。") | ||
| 531 | + if result["notes"]: | ||
| 532 | + limitations.extend(result["notes"]) | ||
| 533 | + if not limitations: | ||
| 534 | + limitations.append("报告基于 GitCode PR 元数据、文件列表和 patch 摘要生成。") | ||
| 535 | + | ||
| 536 | + lines: list[str] = [] | ||
| 537 | + lines.append("## 🤖 CANN 代码审查报告") | ||
| 538 | + lines.append("") | ||
| 539 | + lines.append(f"**PR**: {parsed['project_path']}#{parsed['pr_number']} - {summary.get('title', '(unknown)')}") | ||
| 540 | + lines.append(f"**严重性**: {severity_label}") | ||
| 541 | + lines.append(f"**审查时间**: {now_text}") | ||
| 542 | + lines.append("") | ||
| 543 | + lines.append("---") | ||
| 544 | + lines.append("") | ||
| 545 | + lines.append("### 📊 审查结论") | ||
| 546 | + lines.append("") | ||
| 547 | + lines.append(f"**{conclusion}**") | ||
| 548 | + lines.append("") | ||
| 549 | + lines.append(f"- **严重性**: {review.get('severity', 'unknown')}") | ||
| 550 | + lines.append(f"- **风险分**: {review.get('score', 'n/a')}") | ||
| 551 | + lines.append(f"- **文件规模**: {summary.get('files_changed', 0)} 个文件,+{summary.get('additions', 0)} / -{summary.get('deletions', 0)}") | ||
| 552 | + lines.append(f"- **测试信息**: {test_label}") | ||
| 553 | + lines.append("") | ||
| 554 | + lines.append(review.get("summary") or "未生成摘要。") | ||
| 555 | + lines.append("") | ||
| 556 | + lines.append("---") | ||
| 557 | + lines.append("") | ||
| 558 | + lines.append("### 📋 修改概述") | ||
| 559 | + lines.append("") | ||
| 560 | + lines.append(f"- **作者**: {summary.get('author', '(unknown)')}") | ||
| 561 | + lines.append(f"- **分支**: {summary.get('head_branch', '(unknown)')} -> {summary.get('base_branch', '(unknown)')}") | ||
| 562 | + lines.append(f"- **主要影响区域**: {top_areas}") | ||
| 563 | + lines.append(f"- **标签**: {', '.join(summary.get('labels') or []) or '(none)'}") | ||
| 564 | + lines.append("") | ||
| 565 | + lines.append("---") | ||
| 566 | + lines.append("") | ||
| 567 | + lines.append("### 🔍 主要关注点") | ||
| 568 | + lines.append("") | ||
| 569 | + for item in review.get("concerns") or ["未识别到明显关注点。"]: | ||
| 570 | + lines.append(f"- {item}") | ||
| 571 | + lines.append("") | ||
| 572 | + lines.append("---") | ||
| 573 | + lines.append("") | ||
| 574 | + lines.append("### ✅ 正向信号") | ||
| 575 | + lines.append("") | ||
| 576 | + for item in review.get("positives") or ["未识别到明显正向信号。"]: | ||
| 577 | + lines.append(f"- {item}") | ||
| 578 | + lines.append("") | ||
| 579 | + if top_files: | ||
| 580 | + lines.append("---") | ||
| 581 | + lines.append("") | ||
| 582 | + lines.append("### 📁 重点文件") | ||
| 583 | + lines.append("") | ||
| 584 | + for item in top_files: | ||
| 585 | + lines.append( | ||
| 586 | + f"- `{item['filename']}`: {item['status']},+{item['additions']} / -{item['deletions']}" | ||
| 587 | + ) | ||
| 588 | + lines.append("") | ||
| 589 | + lines.append("---") | ||
| 590 | + lines.append("") | ||
| 591 | + lines.append("### 💡 建议动作") | ||
| 592 | + lines.append("") | ||
| 593 | + for item in review.get("next_actions") or ["建议人工补充核查后再处理。"]: | ||
| 594 | + lines.append(f"- {item}") | ||
| 595 | + lines.append("") | ||
| 596 | + lines.append("---") | ||
| 597 | + lines.append("") | ||
| 598 | + lines.append("### 📝 审查说明") | ||
| 599 | + lines.append("") | ||
| 600 | + for item in limitations: | ||
| 601 | + lines.append(f"- {item}") | ||
| 602 | + lines.append("") | ||
| 603 | + | ||
| 604 | + actions = result.get("actions") or {} | ||
| 605 | + action_lines: list[str] = [] | ||
| 606 | + if actions.get("comment_posted"): | ||
| 607 | + action_lines.append(f"已发布审查评论,comment_id={actions.get('comment_id')}") | ||
| 608 | + if actions.get("literal_lgtm_posted"): | ||
| 609 | + action_lines.append( | ||
| 610 | + f"已按显式请求发布字面量 `/lgtm`,comment_id={actions.get('literal_lgtm_comment_id')}" | ||
| 611 | + ) | ||
| 612 | + if actions.get("lgtm_posted"): | ||
| 613 | + action_lines.append(f"已发布字面量 `/lgtm`,comment_id={actions.get('lgtm_comment_id')}") | ||
| 614 | + for item in actions.get("skipped") or []: | ||
| 615 | + action_lines.append(item) | ||
| 616 | + if action_lines: | ||
| 617 | + lines.append("---") | ||
| 618 | + lines.append("") | ||
| 619 | + lines.append("### 🚀 执行结果") | ||
| 620 | + lines.append("") | ||
| 621 | + for item in action_lines: | ||
| 622 | + lines.append(f"- {item}") | ||
| 623 | + lines.append("") | ||
| 624 | + | ||
| 625 | + return "\n".join(lines).rstrip() + "\n" | ||
| 626 | + | ||
| 627 | + | ||
| 628 | +def build_file_context( | ||
| 629 | + files_data: list[dict[str, Any]], | ||
| 630 | + max_files: int, | ||
| 631 | + max_patch_chars: int, | ||
| 632 | + max_patch_chars_per_file: int, | ||
| 633 | +) -> tuple[list[dict[str, Any]], dict[str, Any]]: | ||
| 634 | + selected = files_data[: max(0, max_files)] | ||
| 635 | + remaining_patch_budget = max(0, max_patch_chars) | ||
| 636 | + rendered_files: list[dict[str, Any]] = [] | ||
| 637 | + omitted_files = max(0, len(files_data) - len(selected)) | ||
| 638 | + | ||
| 639 | + for item in selected: | ||
| 640 | + patch_text = extract_patch(item) | ||
| 641 | + per_file_limit = min(max_patch_chars_per_file, remaining_patch_budget) | ||
| 642 | + patch_excerpt, truncated = clip_text(patch_text, per_file_limit) | ||
| 643 | + remaining_patch_budget = max(0, remaining_patch_budget - min(len(patch_text), per_file_limit)) | ||
| 644 | + | ||
| 645 | + rendered_files.append( | ||
| 646 | + { | ||
| 647 | + "filename": item.get("filename"), | ||
| 648 | + "status": infer_file_status(item), | ||
| 649 | + "additions": int(item.get("additions") or 0), | ||
| 650 | + "deletions": int(item.get("deletions") or 0), | ||
| 651 | + "previous_path": (item.get("patch") or {}).get("old_path") if isinstance(item.get("patch"), dict) else None, | ||
| 652 | + "too_large": bool(item.get("too_large")), | ||
| 653 | + "patch_included": bool(patch_excerpt), | ||
| 654 | + "patch_truncated": truncated, | ||
| 655 | + "patch_excerpt": patch_excerpt, | ||
| 656 | + } | ||
| 657 | + ) | ||
| 658 | + | ||
| 659 | + meta = { | ||
| 660 | + "selected_files": len(rendered_files), | ||
| 661 | + "omitted_files": omitted_files, | ||
| 662 | + "total_files": len(files_data), | ||
| 663 | + "remaining_patch_budget": remaining_patch_budget, | ||
| 664 | + } | ||
| 665 | + return rendered_files, meta | ||
| 666 | + | ||
| 667 | + | ||
| 668 | +def render_context_markdown(result: dict[str, Any]) -> str: | ||
| 669 | + lines: list[str] = [] | ||
| 670 | + parsed = result["parsed"] | ||
| 671 | + lines.append("# GitCode PR Context") | ||
| 672 | + lines.append("") | ||
| 673 | + lines.append("## Parsed URL") | ||
| 674 | + lines.append(f"- Source URL: {parsed['source_url']}") | ||
| 675 | + lines.append(f"- Project: {parsed['project_path']}") | ||
| 676 | + lines.append(f"- PR Number: {parsed['pr_number']}") | ||
| 677 | + lines.append(f"- Input Kind: {parsed['input_kind']}") | ||
| 678 | + lines.append(f"- Canonical Web URL: {parsed['canonical_web_url']}") | ||
| 679 | + lines.append(f"- API URL: {parsed['api_url']}") | ||
| 680 | + lines.append("") | ||
| 681 | + lines.append("## Auth") | ||
| 682 | + lines.append(f"- Token Env: {result['auth']['token_env']}") | ||
| 683 | + lines.append(f"- Token Present: {'yes' if result['auth']['token_present'] else 'no'}") | ||
| 684 | + lines.append(f"- Token Source: {result['auth']['token_source']}") | ||
| 685 | + lines.append("") | ||
| 686 | + | ||
| 687 | + if result["notes"]: | ||
| 688 | + lines.append("## Notes") | ||
| 689 | + for note in result["notes"]: | ||
| 690 | + lines.append(f"- {note}") | ||
| 691 | + lines.append("") | ||
| 692 | + | ||
| 693 | + if result["errors"]: | ||
| 694 | + lines.append("## Errors") | ||
| 695 | + for error in result["errors"]: | ||
| 696 | + lines.append(f"- {error}") | ||
| 697 | + lines.append("") | ||
| 698 | + | ||
| 699 | + if result.get("pr"): | ||
| 700 | + pr = result["pr"] | ||
| 701 | + summary = result["summary"] | ||
| 702 | + lines.append("## Remote Metadata") | ||
| 703 | + lines.append(f"- Title: {summary['title']}") | ||
| 704 | + lines.append(f"- State: {summary['state']}") | ||
| 705 | + lines.append(f"- Author: {summary['author']}") | ||
| 706 | + lines.append(f"- Base: {summary['base_repo']} @ {summary['base_branch']}") | ||
| 707 | + lines.append(f"- Head: {summary['head_repo']} @ {summary['head_branch']}") | ||
| 708 | + lines.append(f"- Mergeable: {summary['mergeable']}") | ||
| 709 | + lines.append(f"- Labels: {', '.join(summary['labels']) if summary['labels'] else '(none)'}") | ||
| 710 | + lines.append(f"- Files Changed: {summary['files_changed']}") | ||
| 711 | + lines.append(f"- Additions / Deletions: {summary['additions']} / {summary['deletions']}") | ||
| 712 | + lines.append(f"- Canonical API html_url: {pr.get('html_url')}") | ||
| 713 | + lines.append("") | ||
| 714 | + if pr.get("body"): | ||
| 715 | + lines.append("## PR Body") | ||
| 716 | + lines.append("```markdown") | ||
| 717 | + lines.append(str(pr["body"]).rstrip()) | ||
| 718 | + lines.append("```") | ||
| 719 | + lines.append("") | ||
| 720 | + | ||
| 721 | + if result.get("files"): | ||
| 722 | + meta = result["files_meta"] | ||
| 723 | + lines.append("## Changed Files") | ||
| 724 | + lines.append(f"- Included: {meta['selected_files']}") | ||
| 725 | + lines.append(f"- Omitted: {meta['omitted_files']}") | ||
| 726 | + lines.append("") | ||
| 727 | + for index, file_item in enumerate(result["files"], start=1): | ||
| 728 | + lines.append(f"### {index}. {file_item['filename']}") | ||
| 729 | + lines.append(f"- Status: {file_item['status']}") | ||
| 730 | + lines.append(f"- Additions / Deletions: {file_item['additions']} / {file_item['deletions']}") | ||
| 731 | + if file_item.get("previous_path"): | ||
| 732 | + lines.append(f"- Previous Path: {file_item['previous_path']}") | ||
| 733 | + if file_item["too_large"]: | ||
| 734 | + lines.append("- Patch: marked too_large by GitCode") | ||
| 735 | + elif file_item["patch_included"]: | ||
| 736 | + lines.append("```diff") | ||
| 737 | + lines.append(file_item["patch_excerpt"].rstrip()) | ||
| 738 | + lines.append("```") | ||
| 739 | + else: | ||
| 740 | + lines.append("- Patch: omitted") | ||
| 741 | + lines.append("") | ||
| 742 | + | ||
| 743 | + return "\n".join(lines).rstrip() + "\n" | ||
| 744 | + | ||
| 745 | + | ||
| 746 | +def build_result(args: argparse.Namespace) -> tuple[dict[str, Any], int]: | ||
| 747 | + parsed = parse_pr_url(args.pr_url) | ||
| 748 | + token, token_source = resolve_token(args.token_env) | ||
| 749 | + | ||
| 750 | + result: dict[str, Any] = { | ||
| 751 | + "parsed": parsed, | ||
| 752 | + "auth": { | ||
| 753 | + "token_env": args.token_env, | ||
| 754 | + "token_present": bool(token), | ||
| 755 | + "token_source": token_source, | ||
| 756 | + }, | ||
| 757 | + "summary": None, | ||
| 758 | + "review": None, | ||
| 759 | + "pr": None, | ||
| 760 | + "files": [], | ||
| 761 | + "files_meta": None, | ||
| 762 | + "actions": { | ||
| 763 | + "comment_posted": False, | ||
| 764 | + "comment_id": None, | ||
| 765 | + "lgtm_posted": False, | ||
| 766 | + "lgtm_comment_id": None, | ||
| 767 | + "literal_lgtm_posted": False, | ||
| 768 | + "literal_lgtm_comment_id": None, | ||
| 769 | + "skipped": [], | ||
| 770 | + }, | ||
| 771 | + "notes": [], | ||
| 772 | + "errors": [], | ||
| 773 | + } | ||
| 774 | + | ||
| 775 | + if not token: | ||
| 776 | + result["notes"].append( | ||
| 777 | + f"{args.token_env} is not set and no fallback config file was found. URL parsing completed, but PR metadata and file diffs were not fetched." | ||
| 778 | + ) | ||
| 779 | + return result, 0 | ||
| 780 | + if token_source.startswith("config:"): | ||
| 781 | + result["notes"].append(f"Loaded GitCode token from {token_source[7:]}.") | ||
| 782 | + | ||
| 783 | + try: | ||
| 784 | + pr_data = fetch_json(parsed["api_url"], token, args.timeout) | ||
| 785 | + files_data: list[dict[str, Any]] = [] | ||
| 786 | + if not args.skip_files: | ||
| 787 | + raw_files = fetch_json(parsed["files_api_url"], token, args.timeout) | ||
| 788 | + if isinstance(raw_files, list): | ||
| 789 | + files_data = raw_files | ||
| 790 | + else: | ||
| 791 | + raise GitCodeApiError("GitCode files API did not return a list") | ||
| 792 | + | ||
| 793 | + result["pr"] = pr_data | ||
| 794 | + result["summary"] = summarize_pr(pr_data, files_data) | ||
| 795 | + result["review"] = build_review(pr_data, files_data, result["summary"]) | ||
| 796 | + result["parsed"]["canonical_web_url"] = pr_data.get("html_url") or result["parsed"]["canonical_web_url"] | ||
| 797 | + files_context, files_meta = build_file_context( | ||
| 798 | + files_data, | ||
| 799 | + max_files=args.max_files, | ||
| 800 | + max_patch_chars=args.max_patch_chars, | ||
| 801 | + max_patch_chars_per_file=args.max_patch_chars_per_file, | ||
| 802 | + ) | ||
| 803 | + result["files"] = files_context | ||
| 804 | + result["files_meta"] = files_meta | ||
| 805 | + return result, 0 | ||
| 806 | + except GitCodeApiError as exc: | ||
| 807 | + result["errors"].append(str(exc)) | ||
| 808 | + return result, 1 | ||
| 809 | + | ||
| 810 | + | ||
| 811 | +def execute_actions(args: argparse.Namespace, result: dict[str, Any]) -> int: | ||
| 812 | + if not args.post_comment and not args.auto_lgtm and not args.lgtm_only: | ||
| 813 | + return 0 | ||
| 814 | + | ||
| 815 | + if result["errors"]: | ||
| 816 | + result["actions"]["skipped"].append("存在错误,未执行评论或 LGTM 动作。") | ||
| 817 | + return 1 | ||
| 818 | + | ||
| 819 | + if not result.get("pr") or not result.get("summary"): | ||
| 820 | + result["actions"]["skipped"].append("未获取到完整 PR 信息,未执行评论或 LGTM 动作。") | ||
| 821 | + return 1 | ||
| 822 | + | ||
| 823 | + token, _token_source = resolve_token(args.token_env) | ||
| 824 | + if not token: | ||
| 825 | + result["actions"]["skipped"].append("缺少 GitCode token,未执行评论或 LGTM 动作。") | ||
| 826 | + return 1 | ||
| 827 | + | ||
| 828 | + parsed = result["parsed"] | ||
| 829 | + review = result.get("review") or {} | ||
| 830 | + | ||
| 831 | + try: | ||
| 832 | + if args.post_comment: | ||
| 833 | + if args.comment_format == "review": | ||
| 834 | + comment_body = render_review_markdown(result) | ||
| 835 | + else: | ||
| 836 | + comment_body = render_report_markdown(result) | ||
| 837 | + comment_response = post_comment(parsed["comments_api_url"], token, args.timeout, comment_body) | ||
| 838 | + result["actions"]["comment_posted"] = True | ||
| 839 | + result["actions"]["comment_id"] = comment_response.get("id") | ||
| 840 | + | ||
| 841 | + if args.lgtm_only: | ||
| 842 | + lgtm_response = post_comment(parsed["comments_api_url"], token, args.timeout, "/lgtm") | ||
| 843 | + result["actions"]["literal_lgtm_posted"] = True | ||
| 844 | + result["actions"]["literal_lgtm_comment_id"] = lgtm_response.get("id") | ||
| 845 | + | ||
| 846 | + if args.auto_lgtm: | ||
| 847 | + if review.get("decision") == "lgtm": | ||
| 848 | + lgtm_response = post_comment(parsed["comments_api_url"], token, args.timeout, "/lgtm") | ||
| 849 | + result["actions"]["lgtm_posted"] = True | ||
| 850 | + result["actions"]["lgtm_comment_id"] = lgtm_response.get("id") | ||
| 851 | + else: | ||
| 852 | + result["actions"]["skipped"].append("当前判定不是“可直接 LGTM”,已跳过自动 LGTM 命令。") | ||
| 853 | + return 0 | ||
| 854 | + except GitCodeApiError as exc: | ||
| 855 | + result["errors"].append(str(exc)) | ||
| 856 | + return 1 | ||
| 857 | + | ||
| 858 | + | ||
| 859 | +def parse_args() -> argparse.Namespace: | ||
| 860 | + parser = argparse.ArgumentParser( | ||
| 861 | + description="Normalize GitCode PR URLs and fetch PR context for Codex/Claude skills." | ||
| 862 | + ) | ||
| 863 | + parser.add_argument("pr_url", help="GitCode PR URL") | ||
| 864 | + parser.add_argument("--format", choices=("review", "report", "json", "context-markdown", "markdown"), default="review") | ||
| 865 | + parser.add_argument("--token-env", default="GITCODE_TOKEN") | ||
| 866 | + parser.add_argument("--timeout", type=float, default=20.0) | ||
| 867 | + parser.add_argument("--max-files", type=int, default=40) | ||
| 868 | + parser.add_argument("--max-patch-chars", type=int, default=16000) | ||
| 869 | + parser.add_argument("--max-patch-chars-per-file", type=int, default=4000) | ||
| 870 | + parser.add_argument("--skip-files", action="store_true") | ||
| 871 | + parser.add_argument("--post-comment", action="store_true") | ||
| 872 | + parser.add_argument("--comment-format", choices=("review", "report"), default="report") | ||
| 873 | + parser.add_argument("--auto-lgtm", action="store_true") | ||
| 874 | + parser.add_argument("--lgtm-only", action="store_true") | ||
| 875 | + parser.add_argument("--output", help="Optional output file path") | ||
| 876 | + return parser.parse_args() | ||
| 877 | + | ||
| 878 | + | ||
| 879 | +def main() -> int: | ||
| 880 | + args = parse_args() | ||
| 881 | + try: | ||
| 882 | + result, exit_code = build_result(args) | ||
| 883 | + except ValueError as exc: | ||
| 884 | + error_result = { | ||
| 885 | + "errors": [str(exc)], | ||
| 886 | + "notes": [], | ||
| 887 | + } | ||
| 888 | + output = ( | ||
| 889 | + json.dumps(error_result, ensure_ascii=False, indent=2) | ||
| 890 | + if args.format == "json" | ||
| 891 | + else "# CANN PR 快速检视\n\n## 错误\n- " + str(exc) + "\n" | ||
| 892 | + ) | ||
| 893 | + if args.output: | ||
| 894 | + with open(args.output, "w", encoding="utf-8") as handle: | ||
| 895 | + handle.write(output) | ||
| 896 | + else: | ||
| 897 | + sys.stdout.write(output) | ||
| 898 | + return 2 | ||
| 899 | + | ||
| 900 | + action_exit_code = execute_actions(args, result) | ||
| 901 | + | ||
| 902 | + if args.format == "json": | ||
| 903 | + output = json.dumps(result, ensure_ascii=False, indent=2) | ||
| 904 | + elif args.format in {"context-markdown", "markdown"}: | ||
| 905 | + output = render_context_markdown(result) | ||
| 906 | + elif args.format == "report": | ||
| 907 | + output = render_report_markdown(result) | ||
| 908 | + else: | ||
| 909 | + output = render_review_markdown(result) | ||
| 910 | + | ||
| 911 | + if args.output: | ||
| 912 | + with open(args.output, "w", encoding="utf-8") as handle: | ||
| 913 | + handle.write(output) | ||
| 914 | + else: | ||
| 915 | + sys.stdout.write(output) | ||
| 916 | + return max(exit_code, action_exit_code) | ||
| 917 | + | ||
| 918 | + | ||
| 919 | +if __name__ == "__main__": | ||
| 920 | + sys.exit(main()) | ||