已开启
feat: 添加 cann-review 技能 #1
feat: 添加 cann-review 技能 #1
已开启
GengChao创建于 3月19日
6 个文件变更+1546-1
MREADME.md+17-1
@@ -10,6 +10,9 @@ GitCode Skills 是一组用于支持 Claude Code 操作 GitCode 的技能包。
10### gitcode-issue-skill10### 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 code17### cluade code
15### 安装18### 安装
@@ -117,6 +120,19 @@ opencode:
117gitcode-issue 读取https://gitcode.com/cann/ge/issues/153120gitcode-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 -拉取issue185- [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())