msdevflow 0.7.9 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/lib/bootstrap.js +36 -2
- package/package.json +3 -2
- package/skill/msd/README.md +4 -0
- package/skill/msd/SKILL.md +4 -1
- package/skill/msd/references/code-review.md +83 -27
- package/skill/msd/references/command-capabilities.md +9 -3
- package/skill/msd/references/create-issue.md +33 -4
- package/skill/msd/references/state-and-safety.md +8 -2
- package/skill/msd/scripts/gitcode_review.py +1653 -0
|
@@ -0,0 +1,1653 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import uuid
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path, PurePosixPath
|
|
19
|
+
from typing import Any, Callable, Iterable
|
|
20
|
+
|
|
21
|
+
if os.name == "nt":
|
|
22
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
23
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
24
|
+
|
|
25
|
+
TOOL_NAME = "msdevflow-gitcode-review"
|
|
26
|
+
TOOL_VERSION = 1
|
|
27
|
+
SIGNATURE = "——msdevflow"
|
|
28
|
+
SNAPSHOT_MARKER = ".msdevflow-gitcode-review-snapshot"
|
|
29
|
+
SNAPSHOT_MARKER_CONTENT = "managed-by=msdevflow\ntype=gitcode-review-snapshot\n"
|
|
30
|
+
WRITE_RECEIPT_MARKER = ".msdevflow-gitcode-review-write-receipts"
|
|
31
|
+
WRITE_RECEIPT_MARKER_CONTENT = "managed-by=msdevflow\ntype=gitcode-review-write-receipts\n"
|
|
32
|
+
MAX_CONTENT_BYTES = 10 * 1024 * 1024
|
|
33
|
+
MAX_JSON_BYTES = 16 * 1024 * 1024
|
|
34
|
+
MAX_CONTEXT_FILES = 100
|
|
35
|
+
REPOSITORY_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
36
|
+
SHA_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$")
|
|
37
|
+
PR_URL_PATTERN = re.compile(
|
|
38
|
+
r"^https?://gitcode\.com/([^/]+)/([^/]+)/(?:pull|pulls|merge_requests)/(\d+)(?:[/?#].*)?$",
|
|
39
|
+
re.IGNORECASE,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ReviewToolError(RuntimeError):
|
|
44
|
+
def __init__(self, error: str, message: str, exit_code: int = 4) -> None:
|
|
45
|
+
super().__init__(message)
|
|
46
|
+
self.error = error
|
|
47
|
+
self.exit_code = exit_code
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GitCodeCommandError(ReviewToolError):
|
|
51
|
+
def __init__(self, operation: str, reason: str, return_code: int | None = None) -> None:
|
|
52
|
+
suffix = f",退出码 {return_code}" if return_code is not None else ""
|
|
53
|
+
super().__init__("gitcode-command-failed", f"GitCode {operation} 失败:{reason}{suffix}", 3)
|
|
54
|
+
self.operation = operation
|
|
55
|
+
self.return_code = return_code
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class PullRequestTarget:
|
|
60
|
+
repository: str
|
|
61
|
+
number: int
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class GitCodeClient:
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
command: str,
|
|
68
|
+
*,
|
|
69
|
+
runner: Callable[..., Any] = subprocess.run,
|
|
70
|
+
which: Callable[[str], str | None] = shutil.which,
|
|
71
|
+
platform: str | None = None,
|
|
72
|
+
timeout: int = 120,
|
|
73
|
+
) -> None:
|
|
74
|
+
self.platform = platform or os.name
|
|
75
|
+
self.executable = resolve_gitcode_executable(command, which=which, platform=self.platform)
|
|
76
|
+
self.runner = runner
|
|
77
|
+
self.timeout = timeout
|
|
78
|
+
|
|
79
|
+
def _invocation(self, arguments: list[str]) -> list[str]:
|
|
80
|
+
if self.platform in {"nt", "win32"} and Path(self.executable).suffix.lower() in {".cmd", ".bat"}:
|
|
81
|
+
return [
|
|
82
|
+
"powershell.exe",
|
|
83
|
+
"-NoProfile",
|
|
84
|
+
"-Command",
|
|
85
|
+
"& { $exe = $args[0]; $rest = @($args | Select-Object -Skip 1); & $exe @rest; exit $LASTEXITCODE }",
|
|
86
|
+
self.executable,
|
|
87
|
+
*arguments,
|
|
88
|
+
]
|
|
89
|
+
return [self.executable, *arguments]
|
|
90
|
+
|
|
91
|
+
def _run(
|
|
92
|
+
self,
|
|
93
|
+
arguments: list[str],
|
|
94
|
+
operation: str,
|
|
95
|
+
*,
|
|
96
|
+
stdout: Any = subprocess.PIPE,
|
|
97
|
+
) -> Any:
|
|
98
|
+
try:
|
|
99
|
+
result = self.runner(
|
|
100
|
+
self._invocation(arguments),
|
|
101
|
+
stdin=subprocess.DEVNULL,
|
|
102
|
+
stdout=stdout,
|
|
103
|
+
stderr=subprocess.PIPE,
|
|
104
|
+
timeout=self.timeout,
|
|
105
|
+
check=False,
|
|
106
|
+
)
|
|
107
|
+
except FileNotFoundError as error:
|
|
108
|
+
raise GitCodeCommandError(operation, "命令不可用") from error
|
|
109
|
+
except subprocess.TimeoutExpired as error:
|
|
110
|
+
raise GitCodeCommandError(operation, "请求超时") from error
|
|
111
|
+
except OSError as error:
|
|
112
|
+
raise GitCodeCommandError(operation, "无法启动命令") from error
|
|
113
|
+
if result.returncode != 0:
|
|
114
|
+
raise GitCodeCommandError(operation, "远端或 CLI 返回错误", result.returncode)
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
def check(self, arguments: list[str], operation: str) -> None:
|
|
118
|
+
self._run(arguments, operation, stdout=subprocess.DEVNULL)
|
|
119
|
+
|
|
120
|
+
def json(self, arguments: list[str], operation: str) -> Any:
|
|
121
|
+
with tempfile.TemporaryFile() as output_file:
|
|
122
|
+
result = self._run(arguments, operation, stdout=output_file)
|
|
123
|
+
output_file.seek(0, os.SEEK_END)
|
|
124
|
+
output_size = output_file.tell()
|
|
125
|
+
if not output_size and isinstance(result.stdout, (bytes, str)):
|
|
126
|
+
raw_output = result.stdout
|
|
127
|
+
output_size = len(raw_output.encode("utf-8") if isinstance(raw_output, str) else raw_output)
|
|
128
|
+
else:
|
|
129
|
+
output_file.seek(0)
|
|
130
|
+
raw_output = output_file.read()
|
|
131
|
+
if output_size > MAX_JSON_BYTES:
|
|
132
|
+
raise GitCodeCommandError(operation, "stdout JSON 超过 16 MiB 安全上限")
|
|
133
|
+
try:
|
|
134
|
+
output = decode_utf8(raw_output)
|
|
135
|
+
return json.loads(output)
|
|
136
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
137
|
+
raise GitCodeCommandError(operation, "stdout 不是有效 UTF-8 JSON") from error
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def resolve_gitcode_executable(
|
|
141
|
+
command: str,
|
|
142
|
+
*,
|
|
143
|
+
which: Callable[[str], str | None] = shutil.which,
|
|
144
|
+
platform: str | None = None,
|
|
145
|
+
) -> str:
|
|
146
|
+
if command not in {"gitcode", "gitcode-npm"}:
|
|
147
|
+
raise ReviewToolError(
|
|
148
|
+
"invalid-gitcode-command",
|
|
149
|
+
"--gitcode-command 只允许本次环境已固定的 gitcode 或 gitcode-npm。",
|
|
150
|
+
2,
|
|
151
|
+
)
|
|
152
|
+
selected = which(command) or ""
|
|
153
|
+
if not selected:
|
|
154
|
+
raise ReviewToolError("gitcode-command-not-found", f"找不到 GitCode 命令:{command}", 3)
|
|
155
|
+
if (platform or os.name) in {"nt", "win32"}:
|
|
156
|
+
suffix = Path(selected).suffix.lower()
|
|
157
|
+
if suffix not in {".exe", ".com", ".cmd", ".bat"}:
|
|
158
|
+
raise ReviewToolError(
|
|
159
|
+
"gitcode-command-not-executable",
|
|
160
|
+
"Windows 下 GitCode 命令必须解析为 .exe、.com、.cmd 或 .bat,不能使用 PowerShell shim。",
|
|
161
|
+
3,
|
|
162
|
+
)
|
|
163
|
+
return selected
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def decode_utf8(value: bytes | str) -> str:
|
|
167
|
+
if isinstance(value, str):
|
|
168
|
+
return value.removeprefix("")
|
|
169
|
+
return value.decode("utf-8-sig")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def is_strict_int(value: Any) -> bool:
|
|
173
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def parse_pr_target(value: str, default_repository: str | None = None) -> PullRequestTarget:
|
|
177
|
+
candidate = value.strip()
|
|
178
|
+
match = PR_URL_PATTERN.fullmatch(candidate)
|
|
179
|
+
if match:
|
|
180
|
+
repository = f"{match.group(1)}/{match.group(2)}"
|
|
181
|
+
number = int(match.group(3))
|
|
182
|
+
elif candidate.isdigit():
|
|
183
|
+
if not default_repository:
|
|
184
|
+
raise ReviewToolError("repository-required", "纯 PR 编号必须同时提供 --repo owner/repo。", 2)
|
|
185
|
+
repository = default_repository
|
|
186
|
+
number = int(candidate)
|
|
187
|
+
else:
|
|
188
|
+
shorthand = re.fullmatch(r"([^#]+)#(\d+)", candidate)
|
|
189
|
+
if not shorthand:
|
|
190
|
+
raise ReviewToolError("invalid-pr-target", f"无法解析 PR 目标:{value}", 2)
|
|
191
|
+
repository = shorthand.group(1)
|
|
192
|
+
number = int(shorthand.group(2))
|
|
193
|
+
if not REPOSITORY_PATTERN.fullmatch(repository) or number <= 0:
|
|
194
|
+
raise ReviewToolError("invalid-pr-target", f"无效 PR 目标:{value}", 2)
|
|
195
|
+
if default_repository and candidate.isdigit() and repository != default_repository:
|
|
196
|
+
raise ReviewToolError("repository-mismatch", "PR 目标仓库与 --repo 不一致。", 2)
|
|
197
|
+
return PullRequestTarget(repository, number)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def account_login(value: Any) -> str:
|
|
201
|
+
if isinstance(value, str):
|
|
202
|
+
return value
|
|
203
|
+
if not isinstance(value, dict):
|
|
204
|
+
return ""
|
|
205
|
+
for key in ("login", "username", "name"):
|
|
206
|
+
candidate = value.get(key)
|
|
207
|
+
if isinstance(candidate, str) and candidate:
|
|
208
|
+
return candidate
|
|
209
|
+
return ""
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def authentication_summary(payload: Any) -> dict[str, Any]:
|
|
213
|
+
if not isinstance(payload, dict):
|
|
214
|
+
raise ReviewToolError("authentication-invalid", "GitCode 认证状态不是预期 JSON。", 3)
|
|
215
|
+
username = account_login(payload) or account_login(payload.get("user"))
|
|
216
|
+
logged_in = payload.get("logged_in")
|
|
217
|
+
if logged_in is None:
|
|
218
|
+
logged_in = payload.get("authenticated")
|
|
219
|
+
summary = {"logged_in": bool(logged_in), "username": username}
|
|
220
|
+
if not summary["logged_in"] or not username:
|
|
221
|
+
raise ReviewToolError("authentication-required", "GitCode CLI 尚未认证或无法确定当前账号。", 3)
|
|
222
|
+
return summary
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def schema_flags(payload: Any) -> set[str]:
|
|
226
|
+
if not isinstance(payload, dict):
|
|
227
|
+
return set()
|
|
228
|
+
flags = payload.get("flags")
|
|
229
|
+
if not isinstance(flags, list):
|
|
230
|
+
return set()
|
|
231
|
+
return {
|
|
232
|
+
item.get("name")
|
|
233
|
+
for item in flags
|
|
234
|
+
if isinstance(item, dict) and isinstance(item.get("name"), str)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def validate_capabilities(client: GitCodeClient, *, write: bool = False, inline: bool = False) -> None:
|
|
239
|
+
requirements = {
|
|
240
|
+
"issue view": {"repo", "json"},
|
|
241
|
+
"pr view": {"repo", "json"},
|
|
242
|
+
"pr comments": {"repo", "json"},
|
|
243
|
+
}
|
|
244
|
+
if write:
|
|
245
|
+
required = {"repo", "body-file", "json"}
|
|
246
|
+
if inline:
|
|
247
|
+
required.update({"path", "position"})
|
|
248
|
+
requirements["pr comment"] = required
|
|
249
|
+
for schema, required in requirements.items():
|
|
250
|
+
payload = client.json(["schema", schema], f"schema {schema}")
|
|
251
|
+
missing = required - schema_flags(payload)
|
|
252
|
+
if missing:
|
|
253
|
+
names = ", ".join(sorted(missing))
|
|
254
|
+
raise ReviewToolError(
|
|
255
|
+
"review-capability-required",
|
|
256
|
+
f"GitCode CLI 的 {schema} 缺少能力:{names}。请运行 npx msdevflow@latest setup。",
|
|
257
|
+
3,
|
|
258
|
+
)
|
|
259
|
+
try:
|
|
260
|
+
client.check(["api", "--help"], "check api capability")
|
|
261
|
+
except GitCodeCommandError as error:
|
|
262
|
+
raise ReviewToolError(
|
|
263
|
+
"review-capability-required",
|
|
264
|
+
"GitCode CLI 缺少固定 review helper 所需的 api 能力。请运行 npx msdevflow@latest setup。",
|
|
265
|
+
3,
|
|
266
|
+
) from error
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def pull_request_summary(payload: Any, target: PullRequestTarget) -> dict[str, Any]:
|
|
270
|
+
if not isinstance(payload, dict):
|
|
271
|
+
raise ReviewToolError("pr-json-invalid", "PR JSON 不是对象。", 3)
|
|
272
|
+
head = payload.get("head") if isinstance(payload.get("head"), dict) else {}
|
|
273
|
+
base = payload.get("base") if isinstance(payload.get("base"), dict) else {}
|
|
274
|
+
head_repo = head.get("repo") if isinstance(head.get("repo"), dict) else {}
|
|
275
|
+
base_repo = base.get("repo") if isinstance(base.get("repo"), dict) else {}
|
|
276
|
+
number = payload.get("number")
|
|
277
|
+
if number != target.number:
|
|
278
|
+
raise ReviewToolError("pr-target-mismatch", "PR 回读编号与请求目标不一致。", 3)
|
|
279
|
+
canonical = base_repo.get("full_name") or target.repository
|
|
280
|
+
if canonical != target.repository:
|
|
281
|
+
raise ReviewToolError("pr-target-mismatch", "PR 回读 canonical repository 与请求目标不一致。", 3)
|
|
282
|
+
return {
|
|
283
|
+
"repository": target.repository,
|
|
284
|
+
"number": number,
|
|
285
|
+
"title": payload.get("title") if isinstance(payload.get("title"), str) else "",
|
|
286
|
+
"state": str(payload.get("state") or "").lower(),
|
|
287
|
+
"draft": bool(payload.get("draft") or payload.get("work_in_progress") or payload.get("wip")),
|
|
288
|
+
"author": account_login(payload.get("user") or payload.get("author")),
|
|
289
|
+
"head_sha": str(head.get("sha") or ""),
|
|
290
|
+
"head_ref": str(head.get("ref") or head.get("label") or ""),
|
|
291
|
+
"head_repository": str(head_repo.get("full_name") or ""),
|
|
292
|
+
"base_sha": str(base.get("sha") or ""),
|
|
293
|
+
"base_ref": str(base.get("ref") or base.get("label") or ""),
|
|
294
|
+
"base_repository": str(base_repo.get("full_name") or target.repository),
|
|
295
|
+
"changed_files": payload.get("changed_files"),
|
|
296
|
+
"additions": payload.get("additions"),
|
|
297
|
+
"deletions": payload.get("deletions"),
|
|
298
|
+
"html_url": str(payload.get("html_url") or ""),
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def unwrap_comments(payload: Any) -> list[dict[str, Any]]:
|
|
303
|
+
if isinstance(payload, dict):
|
|
304
|
+
payload = payload.get("comments")
|
|
305
|
+
if not isinstance(payload, list) or any(not isinstance(item, dict) for item in payload):
|
|
306
|
+
raise ReviewToolError("comments-json-invalid", "PR comments JSON 不是完整对象列表。", 3)
|
|
307
|
+
return payload
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def normalize_newlines(value: str) -> str:
|
|
311
|
+
return value.replace("\r\n", "\n").replace("\r", "\n")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def normalize_comment(payload: dict[str, Any]) -> dict[str, Any]:
|
|
315
|
+
position = payload.get("diff_position")
|
|
316
|
+
if not isinstance(position, dict):
|
|
317
|
+
position = payload.get("position") if isinstance(payload.get("position"), dict) else {}
|
|
318
|
+
user = payload.get("user") or payload.get("author")
|
|
319
|
+
return {
|
|
320
|
+
"id": payload.get("id"),
|
|
321
|
+
"discussion_id": str(payload.get("discussion_id") or ""),
|
|
322
|
+
"body": normalize_newlines(str(payload.get("body") or "")),
|
|
323
|
+
"author": account_login(user),
|
|
324
|
+
"resolved": bool(payload.get("resolved")),
|
|
325
|
+
"comment_type": str(payload.get("comment_type") or ""),
|
|
326
|
+
"path": str(
|
|
327
|
+
payload.get("diff_file")
|
|
328
|
+
or position.get("new_path")
|
|
329
|
+
or position.get("old_path")
|
|
330
|
+
or ""
|
|
331
|
+
),
|
|
332
|
+
"new_line": position.get("new_line") or position.get("start_new_line") or position.get("end_new_line"),
|
|
333
|
+
"head_sha": str(position.get("head_sha") or ""),
|
|
334
|
+
"created_at": payload.get("created_at"),
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def normalize_comment_detail(payload: Any) -> dict[str, Any]:
|
|
339
|
+
if not isinstance(payload, dict):
|
|
340
|
+
raise ReviewToolError("comment-detail-invalid", "PR comment detail JSON 不是对象。", 3)
|
|
341
|
+
position = payload.get("position") if isinstance(payload.get("position"), dict) else {}
|
|
342
|
+
return {
|
|
343
|
+
"id": payload.get("id"),
|
|
344
|
+
"discussion_id": str(payload.get("discussion_id") or ""),
|
|
345
|
+
"body": normalize_newlines(str(payload.get("body") or "")),
|
|
346
|
+
"author": account_login(payload.get("user") or payload.get("author")),
|
|
347
|
+
"resolved": bool(payload.get("resolved")),
|
|
348
|
+
"comment_type": str(payload.get("comment_type") or ""),
|
|
349
|
+
"outdated": payload.get("is_outdated") is True,
|
|
350
|
+
"path": str(position.get("new_path") or position.get("old_path") or ""),
|
|
351
|
+
"new_line": position.get("new_line"),
|
|
352
|
+
"head_sha": str(position.get("head_sha") or ""),
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def normalized_files(payload: Any) -> list[dict[str, Any]]:
|
|
357
|
+
if not isinstance(payload, list) or any(not isinstance(item, dict) for item in payload):
|
|
358
|
+
raise ReviewToolError("files-json-invalid", "PR changed-files JSON 不是完整对象列表。", 3)
|
|
359
|
+
result = []
|
|
360
|
+
for item in payload:
|
|
361
|
+
patch = item.get("patch") if isinstance(item.get("patch"), dict) else {}
|
|
362
|
+
filename = item.get("filename") or patch.get("new_path") or patch.get("old_path")
|
|
363
|
+
result.append({
|
|
364
|
+
"raw": item,
|
|
365
|
+
"filename": str(filename or ""),
|
|
366
|
+
"old_path": str(patch.get("old_path") or filename or ""),
|
|
367
|
+
"new_path": str(patch.get("new_path") or filename or ""),
|
|
368
|
+
"new_file": bool(patch.get("new_file")),
|
|
369
|
+
"deleted_file": bool(patch.get("deleted_file")),
|
|
370
|
+
"renamed_file": bool(patch.get("renamed_file")),
|
|
371
|
+
"too_large": bool(patch.get("too_large")),
|
|
372
|
+
"diff": patch.get("diff") if isinstance(patch.get("diff"), str) else None,
|
|
373
|
+
"additions": item.get("additions", patch.get("added_lines")),
|
|
374
|
+
"deletions": item.get("deletions", patch.get("removed_lines")),
|
|
375
|
+
})
|
|
376
|
+
return result
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def safe_remote_path(path: str) -> bool:
|
|
380
|
+
if not path or "\\" in path or "\x00" in path:
|
|
381
|
+
return False
|
|
382
|
+
candidate = PurePosixPath(path)
|
|
383
|
+
return not candidate.is_absolute() and all(part not in {"", ".", ".."} for part in candidate.parts)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def artifact_name(index: int, label: str, remote_path: str, suffix: str = "") -> str:
|
|
387
|
+
basename = PurePosixPath(remote_path).name if remote_path else "artifact"
|
|
388
|
+
basename = re.sub(r"[^A-Za-z0-9_.-]", "_", basename)[:80] or "artifact"
|
|
389
|
+
return f"{index:04d}-{label}-{basename}{suffix}"
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def write_json(path: Path, payload: Any) -> None:
|
|
393
|
+
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def api_endpoint(repository: str, tail: str) -> str:
|
|
397
|
+
return f"repos/{repository}/{tail}"
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def content_endpoint(repository: str, path: str, ref: str) -> str:
|
|
401
|
+
encoded_path = urllib.parse.quote(path, safe="/")
|
|
402
|
+
encoded_ref = urllib.parse.quote(ref, safe="")
|
|
403
|
+
return api_endpoint(repository, f"contents/{encoded_path}?ref={encoded_ref}")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def decode_repository_content(payload: Any) -> tuple[bytes, dict[str, Any]]:
|
|
407
|
+
if not isinstance(payload, dict) or payload.get("type") != "file":
|
|
408
|
+
raise ReviewToolError("repository-content-invalid", "仓库 contents API 未返回文件。", 3)
|
|
409
|
+
size = payload.get("size")
|
|
410
|
+
if isinstance(size, int) and size > MAX_CONTENT_BYTES:
|
|
411
|
+
raise ReviewToolError("repository-content-too-large", "仓库文件超过 helper 的 10 MiB 安全上限。", 4)
|
|
412
|
+
if payload.get("encoding") != "base64" or not isinstance(payload.get("content"), str):
|
|
413
|
+
raise ReviewToolError("repository-content-invalid", "仓库文件不是完整 base64 内容。", 3)
|
|
414
|
+
try:
|
|
415
|
+
compact = re.sub(r"\s+", "", payload["content"])
|
|
416
|
+
data = base64.b64decode(compact, validate=True)
|
|
417
|
+
except (ValueError, binascii.Error) as error:
|
|
418
|
+
raise ReviewToolError("repository-content-invalid", "仓库文件 base64 内容无效。", 3) from error
|
|
419
|
+
if len(data) > MAX_CONTENT_BYTES:
|
|
420
|
+
raise ReviewToolError("repository-content-too-large", "仓库文件超过 helper 的 10 MiB 安全上限。", 4)
|
|
421
|
+
if isinstance(size, int) and size != len(data):
|
|
422
|
+
raise ReviewToolError("repository-content-incomplete", "仓库文件长度与 contents API 元数据不一致。", 3)
|
|
423
|
+
try:
|
|
424
|
+
data.decode("utf-8")
|
|
425
|
+
text_encoding = "utf-8"
|
|
426
|
+
except UnicodeDecodeError:
|
|
427
|
+
text_encoding = "binary"
|
|
428
|
+
return data, {
|
|
429
|
+
"sha": str(payload.get("sha") or ""),
|
|
430
|
+
"size": len(data),
|
|
431
|
+
"content_kind": text_encoding,
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def fetch_content(
|
|
436
|
+
client: GitCodeClient,
|
|
437
|
+
repository: str,
|
|
438
|
+
remote_path: str,
|
|
439
|
+
ref: str,
|
|
440
|
+
output_path: Path,
|
|
441
|
+
) -> dict[str, Any]:
|
|
442
|
+
payload = client.json(
|
|
443
|
+
["api", content_endpoint(repository, remote_path, ref)],
|
|
444
|
+
f"read repository content {remote_path}",
|
|
445
|
+
)
|
|
446
|
+
data, metadata = decode_repository_content(payload)
|
|
447
|
+
output_path.write_bytes(data)
|
|
448
|
+
return {
|
|
449
|
+
"remote_path": remote_path,
|
|
450
|
+
"ref": ref,
|
|
451
|
+
"artifact": output_path.name,
|
|
452
|
+
**metadata,
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def applicable_context_paths(tree: Iterable[dict[str, Any]], changed_paths: list[str]) -> list[str]:
|
|
457
|
+
selected: set[str] = set()
|
|
458
|
+
applicable_configs = {
|
|
459
|
+
".pre-commit-config.yaml",
|
|
460
|
+
"cargo.toml",
|
|
461
|
+
"cmakelists.txt",
|
|
462
|
+
"go.mod",
|
|
463
|
+
"makefile",
|
|
464
|
+
"package.json",
|
|
465
|
+
"pyproject.toml",
|
|
466
|
+
"pytest.ini",
|
|
467
|
+
"tox.ini",
|
|
468
|
+
}
|
|
469
|
+
for item in tree:
|
|
470
|
+
if not isinstance(item, dict) or item.get("type") not in {"blob", "file"}:
|
|
471
|
+
continue
|
|
472
|
+
path = str(item.get("path") or "")
|
|
473
|
+
if not safe_remote_path(path):
|
|
474
|
+
continue
|
|
475
|
+
pure = PurePosixPath(path)
|
|
476
|
+
basename = pure.name.lower()
|
|
477
|
+
parent = "" if str(pure.parent) == "." else str(pure.parent)
|
|
478
|
+
applies = not parent or any(
|
|
479
|
+
changed == parent or changed.startswith(f"{parent}/") for changed in changed_paths
|
|
480
|
+
)
|
|
481
|
+
if basename in {"agents.md", "claude.md", "owners"} and applies:
|
|
482
|
+
selected.add(path)
|
|
483
|
+
elif basename == "codeowners" and (applies or parent in {".gitcode", ".github"}):
|
|
484
|
+
selected.add(path)
|
|
485
|
+
elif basename.startswith("contributing") and not parent:
|
|
486
|
+
selected.add(path)
|
|
487
|
+
elif basename.startswith("readme") and applies:
|
|
488
|
+
selected.add(path)
|
|
489
|
+
elif basename in applicable_configs and applies:
|
|
490
|
+
selected.add(path)
|
|
491
|
+
return sorted(selected)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def issue_references(payload: dict[str, Any], repository: str) -> list[dict[str, Any]]:
|
|
495
|
+
body = str(payload.get("body") or payload.get("description") or "")
|
|
496
|
+
references: set[tuple[str, int]] = set()
|
|
497
|
+
for owner, repo, number in re.findall(
|
|
498
|
+
r"https?://gitcode\.com/([^/\s]+)/([^/\s]+)/issues/(\d+)", body, re.IGNORECASE
|
|
499
|
+
):
|
|
500
|
+
candidate = f"{owner}/{repo}"
|
|
501
|
+
if REPOSITORY_PATTERN.fullmatch(candidate):
|
|
502
|
+
references.add((candidate, int(number)))
|
|
503
|
+
for number in re.findall(r"(?i)\bissue\s*#(\d+)\b", body):
|
|
504
|
+
references.add((repository, int(number)))
|
|
505
|
+
return [
|
|
506
|
+
{"repository": candidate, "number": number, "source": "pr-body-reference"}
|
|
507
|
+
for candidate, number in sorted(references)
|
|
508
|
+
]
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def collect_issue_snapshots(
|
|
512
|
+
client: GitCodeClient,
|
|
513
|
+
references: list[dict[str, Any]],
|
|
514
|
+
directory: Path,
|
|
515
|
+
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
516
|
+
if not references:
|
|
517
|
+
return [], []
|
|
518
|
+
directory.mkdir()
|
|
519
|
+
artifacts = []
|
|
520
|
+
incomplete = []
|
|
521
|
+
for index, reference in enumerate(references, start=1):
|
|
522
|
+
repository = reference["repository"]
|
|
523
|
+
number = reference["number"]
|
|
524
|
+
try:
|
|
525
|
+
payload = client.json(
|
|
526
|
+
["issue", "view", str(number), "-R", repository, "--json"],
|
|
527
|
+
f"read linked Issue {repository}#{number}",
|
|
528
|
+
)
|
|
529
|
+
artifact = directory / f"{index:04d}-{repository.replace('/', '--')}-{number}.json"
|
|
530
|
+
write_json(artifact, payload)
|
|
531
|
+
artifacts.append({**reference, "artifact": artifact.name})
|
|
532
|
+
except ReviewToolError as error:
|
|
533
|
+
incomplete.append(f"linked Issue unavailable: {repository}#{number} ({error.error})")
|
|
534
|
+
return artifacts, incomplete
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def create_snapshot_directory(output_root: Path | None = None) -> Path:
|
|
538
|
+
root = (output_root or Path(tempfile.gettempdir()) / "msdevflow-review-snapshots").expanduser().resolve()
|
|
539
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
540
|
+
snapshot = root / f"review-{uuid.uuid4().hex}"
|
|
541
|
+
snapshot.mkdir(mode=0o700)
|
|
542
|
+
(snapshot / SNAPSHOT_MARKER).write_text(SNAPSHOT_MARKER_CONTENT, encoding="utf-8", newline="\n")
|
|
543
|
+
return snapshot
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def collect_comment_details(
|
|
547
|
+
client: GitCodeClient,
|
|
548
|
+
repository: str,
|
|
549
|
+
comments: list[dict[str, Any]],
|
|
550
|
+
directory: Path,
|
|
551
|
+
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
552
|
+
details = []
|
|
553
|
+
incomplete = []
|
|
554
|
+
directory.mkdir()
|
|
555
|
+
for index, comment in enumerate(comments, start=1):
|
|
556
|
+
normalized = normalize_comment(comment)
|
|
557
|
+
numeric_id = normalized["id"]
|
|
558
|
+
label = normalized["discussion_id"] or numeric_id or index
|
|
559
|
+
comment_type = normalized["comment_type"].lower()
|
|
560
|
+
numeric_id_valid = (
|
|
561
|
+
is_strict_int(numeric_id)
|
|
562
|
+
and numeric_id > 0
|
|
563
|
+
)
|
|
564
|
+
if not numeric_id_valid:
|
|
565
|
+
incomplete.append(f"comment {label} has no numeric ID")
|
|
566
|
+
if not normalized["discussion_id"]:
|
|
567
|
+
incomplete.append(f"comment {label} has no discussion ID")
|
|
568
|
+
if not normalized["author"]:
|
|
569
|
+
incomplete.append(f"comment {label} has no author")
|
|
570
|
+
if not isinstance(comment.get("body"), str):
|
|
571
|
+
incomplete.append(f"comment {label} body is unavailable")
|
|
572
|
+
if not isinstance(comment.get("resolved"), bool):
|
|
573
|
+
incomplete.append(f"comment {label} resolution is unavailable")
|
|
574
|
+
if comment_type not in {"pr_comment", "diff_comment"}:
|
|
575
|
+
incomplete.append(f"comment {label} has unsupported type {comment_type or 'unknown'}")
|
|
576
|
+
has_position = bool(
|
|
577
|
+
normalized["path"] or normalized["new_line"] or normalized["head_sha"]
|
|
578
|
+
)
|
|
579
|
+
if has_position and comment_type != "diff_comment":
|
|
580
|
+
incomplete.append(f"comment {label} has position but is not a diff comment")
|
|
581
|
+
requires_detail = comment_type == "diff_comment" or has_position
|
|
582
|
+
if not requires_detail:
|
|
583
|
+
details.append({"comment": normalized, "detail": None})
|
|
584
|
+
continue
|
|
585
|
+
if not numeric_id_valid:
|
|
586
|
+
details.append({"comment": normalized, "detail": None})
|
|
587
|
+
continue
|
|
588
|
+
try:
|
|
589
|
+
payload = client.json(
|
|
590
|
+
["api", api_endpoint(repository, f"pulls/comments/{numeric_id}")],
|
|
591
|
+
f"read pull comment {numeric_id}",
|
|
592
|
+
)
|
|
593
|
+
detail = normalize_comment_detail(payload)
|
|
594
|
+
write_json(directory / f"{index:04d}-{numeric_id}.json", payload)
|
|
595
|
+
detail_type = detail["comment_type"].lower()
|
|
596
|
+
detail_line = detail["new_line"]
|
|
597
|
+
if (
|
|
598
|
+
not safe_remote_path(detail["path"])
|
|
599
|
+
or not is_strict_int(detail_line)
|
|
600
|
+
or detail_line <= 0
|
|
601
|
+
or not SHA_PATTERN.fullmatch(detail["head_sha"])
|
|
602
|
+
):
|
|
603
|
+
incomplete.append(f"diff comment {numeric_id} detail lacks path, line, or head")
|
|
604
|
+
if not isinstance(payload.get("body"), str):
|
|
605
|
+
incomplete.append(f"diff comment {numeric_id} detail body is unavailable")
|
|
606
|
+
if not isinstance(payload.get("resolved"), bool):
|
|
607
|
+
incomplete.append(f"diff comment {numeric_id} detail resolution is unavailable")
|
|
608
|
+
if "is_outdated" in payload and not isinstance(payload.get("is_outdated"), bool):
|
|
609
|
+
incomplete.append(f"diff comment {numeric_id} detail outdated state is invalid")
|
|
610
|
+
if detail_type not in {"pr_comment", "diff_comment"}:
|
|
611
|
+
incomplete.append(
|
|
612
|
+
f"diff comment {numeric_id} detail has unsupported type "
|
|
613
|
+
f"{detail_type or 'unknown'}"
|
|
614
|
+
)
|
|
615
|
+
if detail_type != comment_type:
|
|
616
|
+
incomplete.append(f"diff comment {numeric_id} detail type mismatches")
|
|
617
|
+
if detail["id"] != numeric_id:
|
|
618
|
+
incomplete.append(f"diff comment {numeric_id} detail ID mismatches")
|
|
619
|
+
if detail["discussion_id"] != normalized["discussion_id"]:
|
|
620
|
+
incomplete.append(f"diff comment {numeric_id} discussion ID mismatches")
|
|
621
|
+
if detail["author"] != normalized["author"]:
|
|
622
|
+
incomplete.append(f"diff comment {numeric_id} author mismatches")
|
|
623
|
+
if detail["body"] != normalized["body"]:
|
|
624
|
+
incomplete.append(f"diff comment {numeric_id} body mismatches")
|
|
625
|
+
if detail["resolved"] != normalized["resolved"]:
|
|
626
|
+
incomplete.append(f"diff comment {numeric_id} resolution mismatches")
|
|
627
|
+
details.append({"comment": normalized, "detail": detail})
|
|
628
|
+
except ReviewToolError:
|
|
629
|
+
incomplete.append(f"diff comment {numeric_id} detail unavailable")
|
|
630
|
+
details.append({"comment": normalized, "detail": None})
|
|
631
|
+
return details, incomplete
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def collect_tree_context(
|
|
635
|
+
client: GitCodeClient,
|
|
636
|
+
repository: str,
|
|
637
|
+
base_ref: str,
|
|
638
|
+
changed_paths: list[str],
|
|
639
|
+
directory: Path,
|
|
640
|
+
) -> tuple[dict[str, Any], list[str]]:
|
|
641
|
+
incomplete = []
|
|
642
|
+
directory.mkdir()
|
|
643
|
+
try:
|
|
644
|
+
encoded_ref = urllib.parse.quote(base_ref, safe="")
|
|
645
|
+
tree_payload = client.json(
|
|
646
|
+
["api", api_endpoint(repository, f"git/trees/{encoded_ref}?recursive=1")],
|
|
647
|
+
"read repository tree",
|
|
648
|
+
)
|
|
649
|
+
except ReviewToolError:
|
|
650
|
+
return {"tree": None, "files": []}, ["repository tree unavailable"]
|
|
651
|
+
write_json(directory / "tree.json", tree_payload)
|
|
652
|
+
tree = tree_payload.get("tree") if isinstance(tree_payload, dict) else None
|
|
653
|
+
if not isinstance(tree, list):
|
|
654
|
+
return {"tree": "tree.json", "files": []}, ["repository tree response is incomplete"]
|
|
655
|
+
if tree_payload.get("truncated") is True:
|
|
656
|
+
incomplete.append("repository tree is truncated")
|
|
657
|
+
paths = applicable_context_paths(tree, changed_paths)
|
|
658
|
+
if len(paths) > MAX_CONTEXT_FILES:
|
|
659
|
+
incomplete.append("applicable repository context exceeds 100 files")
|
|
660
|
+
paths = paths[:MAX_CONTEXT_FILES]
|
|
661
|
+
artifacts = []
|
|
662
|
+
content_dir = directory / "files"
|
|
663
|
+
content_dir.mkdir()
|
|
664
|
+
for index, path in enumerate(paths, start=1):
|
|
665
|
+
artifact = content_dir / artifact_name(index, "base", path)
|
|
666
|
+
try:
|
|
667
|
+
metadata = fetch_content(client, repository, path, base_ref, artifact)
|
|
668
|
+
metadata["artifact"] = str(Path("files") / metadata["artifact"])
|
|
669
|
+
artifacts.append(metadata)
|
|
670
|
+
except ReviewToolError as error:
|
|
671
|
+
incomplete.append(f"repository context unavailable: {path} ({error.error})")
|
|
672
|
+
return {"tree": "tree.json", "tree_truncated": tree_payload.get("truncated"), "files": artifacts}, incomplete
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def collect_pull_request_snapshot(
|
|
676
|
+
client: GitCodeClient,
|
|
677
|
+
target: PullRequestTarget,
|
|
678
|
+
reviewer: str,
|
|
679
|
+
root: Path,
|
|
680
|
+
) -> dict[str, Any]:
|
|
681
|
+
directory = root / f"pr-{target.repository.replace('/', '--')}-{target.number}"
|
|
682
|
+
directory.mkdir()
|
|
683
|
+
pr_payload = client.json(
|
|
684
|
+
["pr", "view", str(target.number), "-R", target.repository, "--json"],
|
|
685
|
+
f"read PR {target.repository}#{target.number}",
|
|
686
|
+
)
|
|
687
|
+
pr = pull_request_summary(pr_payload, target)
|
|
688
|
+
write_json(directory / "pr.json", pr_payload)
|
|
689
|
+
write_json(directory / "pr-summary.json", pr)
|
|
690
|
+
|
|
691
|
+
files_payload = client.json(
|
|
692
|
+
["api", api_endpoint(target.repository, f"pulls/{target.number}/files")],
|
|
693
|
+
f"read PR files {target.repository}#{target.number}",
|
|
694
|
+
)
|
|
695
|
+
write_json(directory / "files.json", files_payload)
|
|
696
|
+
files = normalized_files(files_payload)
|
|
697
|
+
incomplete: list[str] = []
|
|
698
|
+
expected_count = pr.get("changed_files")
|
|
699
|
+
if (
|
|
700
|
+
not is_strict_int(expected_count)
|
|
701
|
+
or expected_count < 0
|
|
702
|
+
):
|
|
703
|
+
incomplete.append("PR changed-files count is unavailable")
|
|
704
|
+
elif expected_count != len(files):
|
|
705
|
+
incomplete.append(f"changed-files count mismatch: expected {expected_count}, got {len(files)}")
|
|
706
|
+
|
|
707
|
+
patch_dir = directory / "patches"
|
|
708
|
+
content_dir = directory / "contents"
|
|
709
|
+
patch_dir.mkdir()
|
|
710
|
+
content_dir.mkdir()
|
|
711
|
+
head_repository = pr["head_repository"]
|
|
712
|
+
head_repository_valid = bool(REPOSITORY_PATTERN.fullmatch(head_repository))
|
|
713
|
+
if not head_repository_valid:
|
|
714
|
+
incomplete.append("PR head repository is unavailable or invalid")
|
|
715
|
+
file_manifest = []
|
|
716
|
+
changed_paths = []
|
|
717
|
+
for index, item in enumerate(files, start=1):
|
|
718
|
+
filename = item["filename"]
|
|
719
|
+
changed_path = item["new_path"] or item["old_path"]
|
|
720
|
+
if safe_remote_path(changed_path):
|
|
721
|
+
changed_paths.append(changed_path)
|
|
722
|
+
entry = {
|
|
723
|
+
key: item[key]
|
|
724
|
+
for key in (
|
|
725
|
+
"filename", "old_path", "new_path", "new_file", "deleted_file", "renamed_file",
|
|
726
|
+
"too_large", "additions", "deletions",
|
|
727
|
+
)
|
|
728
|
+
}
|
|
729
|
+
if not safe_remote_path(filename) or not safe_remote_path(item["old_path"]) or not safe_remote_path(item["new_path"]):
|
|
730
|
+
incomplete.append(f"changed file {index} has an unsafe or empty path")
|
|
731
|
+
entry["complete"] = False
|
|
732
|
+
file_manifest.append(entry)
|
|
733
|
+
continue
|
|
734
|
+
diff = item["diff"]
|
|
735
|
+
if item["too_large"] or diff is None:
|
|
736
|
+
incomplete.append(f"patch unavailable: {filename}")
|
|
737
|
+
entry["patch"] = None
|
|
738
|
+
else:
|
|
739
|
+
patch_path = patch_dir / artifact_name(index, "patch", filename, ".diff")
|
|
740
|
+
patch_path.write_text(diff, encoding="utf-8", newline="\n")
|
|
741
|
+
entry["patch"] = str(Path("patches") / patch_path.name)
|
|
742
|
+
entry["contents"] = {}
|
|
743
|
+
if not item["new_file"]:
|
|
744
|
+
base_path = content_dir / artifact_name(index, "base", item["old_path"])
|
|
745
|
+
try:
|
|
746
|
+
content = fetch_content(
|
|
747
|
+
client, target.repository, item["old_path"], pr["base_ref"], base_path
|
|
748
|
+
)
|
|
749
|
+
content["artifact"] = str(Path("contents") / content["artifact"])
|
|
750
|
+
entry["contents"]["base"] = content
|
|
751
|
+
except ReviewToolError as error:
|
|
752
|
+
incomplete.append(f"base content unavailable: {item['old_path']} ({error.error})")
|
|
753
|
+
if not item["deleted_file"] and head_repository_valid:
|
|
754
|
+
head_path = content_dir / artifact_name(index, "head", item["new_path"])
|
|
755
|
+
try:
|
|
756
|
+
content = fetch_content(
|
|
757
|
+
client, head_repository, item["new_path"], pr["head_ref"], head_path
|
|
758
|
+
)
|
|
759
|
+
content["artifact"] = str(Path("contents") / content["artifact"])
|
|
760
|
+
entry["contents"]["head"] = content
|
|
761
|
+
except ReviewToolError as error:
|
|
762
|
+
incomplete.append(f"head content unavailable: {item['new_path']} ({error.error})")
|
|
763
|
+
entry["complete"] = bool(entry.get("patch")) and (
|
|
764
|
+
item["new_file"] or "base" in entry["contents"]
|
|
765
|
+
) and (
|
|
766
|
+
item["deleted_file"] or "head" in entry["contents"]
|
|
767
|
+
)
|
|
768
|
+
file_manifest.append(entry)
|
|
769
|
+
write_json(directory / "files-manifest.json", file_manifest)
|
|
770
|
+
|
|
771
|
+
comments_payload = client.json(
|
|
772
|
+
["pr", "comments", str(target.number), "-R", target.repository, "--json"],
|
|
773
|
+
f"read PR comments {target.repository}#{target.number}",
|
|
774
|
+
)
|
|
775
|
+
comments = unwrap_comments(comments_payload)
|
|
776
|
+
write_json(directory / "comments.json", comments_payload)
|
|
777
|
+
comment_details, comment_incomplete = collect_comment_details(
|
|
778
|
+
client, target.repository, comments, directory / "comment-details"
|
|
779
|
+
)
|
|
780
|
+
incomplete.extend(comment_incomplete)
|
|
781
|
+
write_json(directory / "comments-manifest.json", comment_details)
|
|
782
|
+
|
|
783
|
+
if pr["base_ref"]:
|
|
784
|
+
context, context_incomplete = collect_tree_context(
|
|
785
|
+
client,
|
|
786
|
+
target.repository,
|
|
787
|
+
pr["base_ref"],
|
|
788
|
+
[path for path in changed_paths if path],
|
|
789
|
+
directory / "repository-context",
|
|
790
|
+
)
|
|
791
|
+
incomplete.extend(context_incomplete)
|
|
792
|
+
else:
|
|
793
|
+
context = {"tree": None, "files": []}
|
|
794
|
+
incomplete.append("PR base ref is unavailable")
|
|
795
|
+
write_json(directory / "repository-context.json", context)
|
|
796
|
+
|
|
797
|
+
references = issue_references(pr_payload, target.repository)
|
|
798
|
+
linked_issues, issue_incomplete = collect_issue_snapshots(
|
|
799
|
+
client, references, directory / "linked-issues"
|
|
800
|
+
)
|
|
801
|
+
incomplete.extend(issue_incomplete)
|
|
802
|
+
write_json(directory / "linked-issues.json", linked_issues)
|
|
803
|
+
|
|
804
|
+
final_pr_payload = client.json(
|
|
805
|
+
["pr", "view", str(target.number), "-R", target.repository, "--json"],
|
|
806
|
+
f"re-read PR {target.repository}#{target.number}",
|
|
807
|
+
)
|
|
808
|
+
final_pr = pull_request_summary(final_pr_payload, target)
|
|
809
|
+
if final_pr["head_sha"] != pr["head_sha"]:
|
|
810
|
+
incomplete.append("PR head changed while collecting snapshot")
|
|
811
|
+
if final_pr["base_sha"] != pr["base_sha"]:
|
|
812
|
+
incomplete.append("PR base changed while collecting snapshot")
|
|
813
|
+
if pr["state"] != "open":
|
|
814
|
+
incomplete.append(f"PR state is {pr['state'] or 'unknown'}")
|
|
815
|
+
if pr["draft"]:
|
|
816
|
+
incomplete.append("PR is draft")
|
|
817
|
+
if not pr["author"]:
|
|
818
|
+
incomplete.append("PR author is unavailable")
|
|
819
|
+
if not SHA_PATTERN.fullmatch(pr["head_sha"]):
|
|
820
|
+
incomplete.append("PR head SHA is unavailable or invalid")
|
|
821
|
+
if not SHA_PATTERN.fullmatch(pr["base_sha"]):
|
|
822
|
+
incomplete.append("PR base SHA is unavailable or invalid")
|
|
823
|
+
if pr["author"] == reviewer:
|
|
824
|
+
incomplete.append("current account is the PR author")
|
|
825
|
+
incomplete = list(dict.fromkeys(incomplete))
|
|
826
|
+
result = {
|
|
827
|
+
"repository": target.repository,
|
|
828
|
+
"number": target.number,
|
|
829
|
+
"reviewer": reviewer,
|
|
830
|
+
"author": pr["author"],
|
|
831
|
+
"head_sha": pr["head_sha"],
|
|
832
|
+
"base_sha": pr["base_sha"],
|
|
833
|
+
"base_ref": pr["base_ref"],
|
|
834
|
+
"state": pr["state"],
|
|
835
|
+
"draft": pr["draft"],
|
|
836
|
+
"directory": directory.name,
|
|
837
|
+
"files_count": len(files),
|
|
838
|
+
"comments_count": len(comments),
|
|
839
|
+
"linked_issues": linked_issues,
|
|
840
|
+
"snapshot_complete": not incomplete,
|
|
841
|
+
"incomplete_reasons": incomplete,
|
|
842
|
+
}
|
|
843
|
+
write_json(directory / "manifest.json", result)
|
|
844
|
+
return result
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def collect_additional_context(
|
|
848
|
+
client: GitCodeClient,
|
|
849
|
+
target: PullRequestTarget,
|
|
850
|
+
expected_head: str,
|
|
851
|
+
side: str,
|
|
852
|
+
paths: list[str],
|
|
853
|
+
) -> dict[str, Any]:
|
|
854
|
+
if not SHA_PATTERN.fullmatch(expected_head):
|
|
855
|
+
raise ReviewToolError("invalid-head", "--expected-head 必须是完整 40 位 SHA。", 2)
|
|
856
|
+
for path in paths:
|
|
857
|
+
if not safe_remote_path(path):
|
|
858
|
+
raise ReviewToolError("context-path-invalid", f"无效仓库上下文路径:{path}", 2)
|
|
859
|
+
payload = client.json(
|
|
860
|
+
["pr", "view", str(target.number), "-R", target.repository, "--json"],
|
|
861
|
+
f"read PR {target.repository}#{target.number}",
|
|
862
|
+
)
|
|
863
|
+
pr = pull_request_summary(payload, target)
|
|
864
|
+
if pr["head_sha"] != expected_head:
|
|
865
|
+
raise ReviewToolError("head-changed", "PR head 已变化,禁止读取旧 head 上下文。", 4)
|
|
866
|
+
head_repository = pr["head_repository"]
|
|
867
|
+
if not REPOSITORY_PATTERN.fullmatch(head_repository):
|
|
868
|
+
raise ReviewToolError("head-repository-invalid", "PR head repository 缺失或无效。", 4)
|
|
869
|
+
selections = []
|
|
870
|
+
if side in {"base", "both"}:
|
|
871
|
+
selections.append(("base", target.repository, pr["base_ref"]))
|
|
872
|
+
if side in {"head", "both"}:
|
|
873
|
+
selections.append(("head", head_repository, pr["head_ref"]))
|
|
874
|
+
if any(not ref for _, _, ref in selections):
|
|
875
|
+
raise ReviewToolError("context-ref-invalid", "PR base/head ref 不完整。", 4)
|
|
876
|
+
snapshot = create_snapshot_directory()
|
|
877
|
+
artifacts = []
|
|
878
|
+
try:
|
|
879
|
+
content_dir = snapshot / "contents"
|
|
880
|
+
content_dir.mkdir()
|
|
881
|
+
index = 0
|
|
882
|
+
for label, repository, ref in selections:
|
|
883
|
+
for path in paths:
|
|
884
|
+
index += 1
|
|
885
|
+
artifact = content_dir / artifact_name(index, label, path)
|
|
886
|
+
metadata = fetch_content(client, repository, path, ref, artifact)
|
|
887
|
+
metadata.update({
|
|
888
|
+
"side": label,
|
|
889
|
+
"repository": repository,
|
|
890
|
+
"artifact": str(Path("contents") / metadata["artifact"]),
|
|
891
|
+
})
|
|
892
|
+
artifacts.append(metadata)
|
|
893
|
+
final_payload = client.json(
|
|
894
|
+
["pr", "view", str(target.number), "-R", target.repository, "--json"],
|
|
895
|
+
f"re-read PR {target.repository}#{target.number}",
|
|
896
|
+
)
|
|
897
|
+
final_pr = pull_request_summary(final_payload, target)
|
|
898
|
+
if final_pr["head_sha"] != expected_head or final_pr["base_sha"] != pr["base_sha"]:
|
|
899
|
+
raise ReviewToolError("head-changed", "读取上下文期间 PR head/base 已变化。", 4)
|
|
900
|
+
manifest = {
|
|
901
|
+
"tool": TOOL_NAME,
|
|
902
|
+
"version": TOOL_VERSION,
|
|
903
|
+
"type": "additional-context",
|
|
904
|
+
"repository": target.repository,
|
|
905
|
+
"number": target.number,
|
|
906
|
+
"head_sha": expected_head,
|
|
907
|
+
"base_sha": pr["base_sha"],
|
|
908
|
+
"artifacts": artifacts,
|
|
909
|
+
}
|
|
910
|
+
write_json(snapshot / "manifest.json", manifest)
|
|
911
|
+
except Exception:
|
|
912
|
+
shutil.rmtree(snapshot)
|
|
913
|
+
raise
|
|
914
|
+
return {
|
|
915
|
+
"tool": TOOL_NAME,
|
|
916
|
+
"version": TOOL_VERSION,
|
|
917
|
+
"state": "context-ready",
|
|
918
|
+
"snapshot_dir": str(snapshot),
|
|
919
|
+
"manifest": str(snapshot / "manifest.json"),
|
|
920
|
+
"repository": target.repository,
|
|
921
|
+
"number": target.number,
|
|
922
|
+
"head_sha": expected_head,
|
|
923
|
+
"artifacts_count": len(artifacts),
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def create_snapshot(
|
|
928
|
+
client: GitCodeClient,
|
|
929
|
+
targets: list[PullRequestTarget],
|
|
930
|
+
output_root: Path | None = None,
|
|
931
|
+
) -> dict[str, Any]:
|
|
932
|
+
auth = authentication_summary(client.json(["auth", "status", "--json"], "read authentication status"))
|
|
933
|
+
snapshot = create_snapshot_directory(output_root)
|
|
934
|
+
results = []
|
|
935
|
+
try:
|
|
936
|
+
for target in targets:
|
|
937
|
+
results.append(collect_pull_request_snapshot(client, target, auth["username"], snapshot))
|
|
938
|
+
manifest = {
|
|
939
|
+
"tool": TOOL_NAME,
|
|
940
|
+
"version": TOOL_VERSION,
|
|
941
|
+
"snapshot_id": snapshot.name,
|
|
942
|
+
"reviewer": auth["username"],
|
|
943
|
+
"prs": results,
|
|
944
|
+
}
|
|
945
|
+
write_json(snapshot / "manifest.json", manifest)
|
|
946
|
+
except Exception:
|
|
947
|
+
shutil.rmtree(snapshot)
|
|
948
|
+
raise
|
|
949
|
+
state = "snapshot-ready" if all(item["snapshot_complete"] for item in results) else "review-incomplete"
|
|
950
|
+
return {
|
|
951
|
+
"tool": TOOL_NAME,
|
|
952
|
+
"version": TOOL_VERSION,
|
|
953
|
+
"state": state,
|
|
954
|
+
"snapshot_dir": str(snapshot),
|
|
955
|
+
"manifest": str(snapshot / "manifest.json"),
|
|
956
|
+
"reviewer": auth["username"],
|
|
957
|
+
"prs": [
|
|
958
|
+
{
|
|
959
|
+
"repository": item["repository"],
|
|
960
|
+
"number": item["number"],
|
|
961
|
+
"head_sha": item["head_sha"],
|
|
962
|
+
"author": item["author"],
|
|
963
|
+
"snapshot_complete": item["snapshot_complete"],
|
|
964
|
+
"incomplete_reasons": item["incomplete_reasons"],
|
|
965
|
+
"manifest": str(snapshot / item["directory"] / "manifest.json"),
|
|
966
|
+
}
|
|
967
|
+
for item in results
|
|
968
|
+
],
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def validate_finding_body(path: Path) -> str:
|
|
973
|
+
data = path.read_bytes()
|
|
974
|
+
if data.startswith(b"\xef\xbb\xbf"):
|
|
975
|
+
raise ReviewToolError("finding-body-invalid", "Finding 正文必须是 UTF-8 无 BOM。", 2)
|
|
976
|
+
try:
|
|
977
|
+
body = normalize_newlines(data.decode("utf-8"))
|
|
978
|
+
except UnicodeDecodeError as error:
|
|
979
|
+
raise ReviewToolError("finding-body-invalid", "Finding 正文不是有效 UTF-8。", 2) from error
|
|
980
|
+
if body.count(SIGNATURE) != 1 or not body.endswith(f"\n\n{SIGNATURE}"):
|
|
981
|
+
raise ReviewToolError(
|
|
982
|
+
"finding-signature-invalid",
|
|
983
|
+
f"Finding 正文必须以一个空行和唯一 {SIGNATURE} 结尾。",
|
|
984
|
+
2,
|
|
985
|
+
)
|
|
986
|
+
return body
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def ensure_review_write_target(
|
|
990
|
+
client: GitCodeClient,
|
|
991
|
+
target: PullRequestTarget,
|
|
992
|
+
expected_head: str,
|
|
993
|
+
) -> tuple[str, dict[str, Any]]:
|
|
994
|
+
if not SHA_PATTERN.fullmatch(expected_head):
|
|
995
|
+
raise ReviewToolError("invalid-head", "--expected-head 必须是完整 40 位 SHA。", 2)
|
|
996
|
+
auth = authentication_summary(client.json(["auth", "status", "--json"], "read authentication status"))
|
|
997
|
+
payload = client.json(
|
|
998
|
+
["pr", "view", str(target.number), "-R", target.repository, "--json"],
|
|
999
|
+
f"read PR {target.repository}#{target.number}",
|
|
1000
|
+
)
|
|
1001
|
+
pr = pull_request_summary(payload, target)
|
|
1002
|
+
if pr["head_sha"] != expected_head:
|
|
1003
|
+
raise ReviewToolError("head-changed", "PR head 已变化,禁止发布旧 head 的检视结论。", 4)
|
|
1004
|
+
if pr["state"] != "open" or pr["draft"]:
|
|
1005
|
+
raise ReviewToolError("pr-not-reviewable", "PR 当前不是 open 且非 Draft 的可检视状态。", 4)
|
|
1006
|
+
if not pr["author"]:
|
|
1007
|
+
raise ReviewToolError("author-unavailable", "无法确认 PR author。", 4)
|
|
1008
|
+
if pr["author"] == auth["username"]:
|
|
1009
|
+
raise ReviewToolError("self-review-forbidden", "当前账号是 PR author,禁止发布 reviewer 结论。", 4)
|
|
1010
|
+
return auth["username"], pr
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def comment_identity(comment: dict[str, Any]) -> tuple[str, str]:
|
|
1014
|
+
normalized = normalize_comment(comment)
|
|
1015
|
+
return str(normalized["id"] or ""), normalized["discussion_id"]
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def write_comment_candidate(
|
|
1019
|
+
comment: dict[str, Any],
|
|
1020
|
+
normalized: dict[str, Any],
|
|
1021
|
+
expected_type: str,
|
|
1022
|
+
) -> bool:
|
|
1023
|
+
numeric_id = normalized["id"]
|
|
1024
|
+
return (
|
|
1025
|
+
is_strict_int(numeric_id)
|
|
1026
|
+
and numeric_id > 0
|
|
1027
|
+
and bool(normalized["discussion_id"])
|
|
1028
|
+
and bool(normalized["author"])
|
|
1029
|
+
and isinstance(comment.get("body"), str)
|
|
1030
|
+
and isinstance(comment.get("resolved"), bool)
|
|
1031
|
+
and normalized["comment_type"].lower() == expected_type
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def read_comments(client: GitCodeClient, target: PullRequestTarget) -> list[dict[str, Any]]:
|
|
1036
|
+
payload = client.json(
|
|
1037
|
+
["pr", "comments", str(target.number), "-R", target.repository, "--json"],
|
|
1038
|
+
f"read PR comments {target.repository}#{target.number}",
|
|
1039
|
+
)
|
|
1040
|
+
return unwrap_comments(payload)
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def read_comment_detail(client: GitCodeClient, repository: str, numeric_id: Any) -> dict[str, Any]:
|
|
1044
|
+
if (
|
|
1045
|
+
not is_strict_int(numeric_id)
|
|
1046
|
+
or numeric_id <= 0
|
|
1047
|
+
):
|
|
1048
|
+
raise ReviewToolError("comment-id-invalid", "评论缺少 numeric comment ID。", 4)
|
|
1049
|
+
payload = client.json(
|
|
1050
|
+
["api", api_endpoint(repository, f"pulls/comments/{numeric_id}")],
|
|
1051
|
+
f"read pull comment {numeric_id}",
|
|
1052
|
+
)
|
|
1053
|
+
detail = normalize_comment_detail(payload)
|
|
1054
|
+
if (
|
|
1055
|
+
detail["id"] != numeric_id
|
|
1056
|
+
or not isinstance(payload.get("body"), str)
|
|
1057
|
+
or not isinstance(payload.get("resolved"), bool)
|
|
1058
|
+
or detail["comment_type"].lower() != "diff_comment"
|
|
1059
|
+
):
|
|
1060
|
+
raise ReviewToolError("comment-detail-mismatch", "评论详情身份或结构与请求不一致。", 4)
|
|
1061
|
+
return detail
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def verified_inline_comment(
|
|
1065
|
+
client: GitCodeClient,
|
|
1066
|
+
target: PullRequestTarget,
|
|
1067
|
+
normalized: dict[str, Any],
|
|
1068
|
+
reviewer: str,
|
|
1069
|
+
body: str,
|
|
1070
|
+
path: str,
|
|
1071
|
+
position: int,
|
|
1072
|
+
expected_head: str,
|
|
1073
|
+
) -> dict[str, Any] | None:
|
|
1074
|
+
detail = read_comment_detail(client, target.repository, normalized["id"])
|
|
1075
|
+
if (
|
|
1076
|
+
detail["discussion_id"] != normalized["discussion_id"]
|
|
1077
|
+
or detail["author"] != normalized["author"]
|
|
1078
|
+
or detail["body"] != normalized["body"]
|
|
1079
|
+
or detail["resolved"] != normalized["resolved"]
|
|
1080
|
+
or detail["comment_type"].lower() != "diff_comment"
|
|
1081
|
+
or detail["author"] != reviewer
|
|
1082
|
+
or detail["body"] != body
|
|
1083
|
+
or detail["path"] != path
|
|
1084
|
+
or detail["new_line"] != position
|
|
1085
|
+
or detail["head_sha"] != expected_head
|
|
1086
|
+
):
|
|
1087
|
+
return None
|
|
1088
|
+
return detail
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def find_existing_inline_comment(
|
|
1092
|
+
client: GitCodeClient,
|
|
1093
|
+
target: PullRequestTarget,
|
|
1094
|
+
comments: list[dict[str, Any]],
|
|
1095
|
+
reviewer: str,
|
|
1096
|
+
body: str,
|
|
1097
|
+
path: str,
|
|
1098
|
+
position: int,
|
|
1099
|
+
expected_head: str,
|
|
1100
|
+
) -> dict[str, Any] | None:
|
|
1101
|
+
matches = []
|
|
1102
|
+
for comment in comments:
|
|
1103
|
+
normalized = normalize_comment(comment)
|
|
1104
|
+
if (
|
|
1105
|
+
not write_comment_candidate(comment, normalized, "diff_comment")
|
|
1106
|
+
or normalized["author"] != reviewer
|
|
1107
|
+
or normalized["body"] != body
|
|
1108
|
+
):
|
|
1109
|
+
continue
|
|
1110
|
+
detail = verified_inline_comment(
|
|
1111
|
+
client,
|
|
1112
|
+
target,
|
|
1113
|
+
normalized,
|
|
1114
|
+
reviewer,
|
|
1115
|
+
body,
|
|
1116
|
+
path,
|
|
1117
|
+
position,
|
|
1118
|
+
expected_head,
|
|
1119
|
+
)
|
|
1120
|
+
if detail:
|
|
1121
|
+
matches.append(detail)
|
|
1122
|
+
if len(matches) > 1:
|
|
1123
|
+
raise ReviewToolError("comment-result-ambiguous", "找到多个等价 finding,无法证明唯一结果。", 4)
|
|
1124
|
+
return matches[0] if matches else None
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def select_written_comment(
|
|
1128
|
+
client: GitCodeClient,
|
|
1129
|
+
target: PullRequestTarget,
|
|
1130
|
+
comments: list[dict[str, Any]],
|
|
1131
|
+
preexisting: set[tuple[str, str]],
|
|
1132
|
+
reviewer: str,
|
|
1133
|
+
body: str,
|
|
1134
|
+
response_id: str,
|
|
1135
|
+
path: str | None,
|
|
1136
|
+
position: int | None,
|
|
1137
|
+
expected_head: str,
|
|
1138
|
+
) -> dict[str, Any]:
|
|
1139
|
+
candidates = []
|
|
1140
|
+
expected_type = "diff_comment" if path is not None else "pr_comment"
|
|
1141
|
+
for raw in comments:
|
|
1142
|
+
normalized = normalize_comment(raw)
|
|
1143
|
+
identity = comment_identity(raw)
|
|
1144
|
+
response_match = bool(
|
|
1145
|
+
response_id
|
|
1146
|
+
and (response_id == identity[0] or response_id == identity[1])
|
|
1147
|
+
)
|
|
1148
|
+
if not response_match and identity in preexisting:
|
|
1149
|
+
continue
|
|
1150
|
+
if (
|
|
1151
|
+
not write_comment_candidate(raw, normalized, expected_type)
|
|
1152
|
+
or normalized["author"] != reviewer
|
|
1153
|
+
or normalized["body"] != body
|
|
1154
|
+
):
|
|
1155
|
+
continue
|
|
1156
|
+
if path is not None:
|
|
1157
|
+
detail = verified_inline_comment(
|
|
1158
|
+
client,
|
|
1159
|
+
target,
|
|
1160
|
+
normalized,
|
|
1161
|
+
reviewer,
|
|
1162
|
+
body,
|
|
1163
|
+
path,
|
|
1164
|
+
position,
|
|
1165
|
+
expected_head,
|
|
1166
|
+
)
|
|
1167
|
+
if not detail:
|
|
1168
|
+
continue
|
|
1169
|
+
normalized = {**normalized, **detail}
|
|
1170
|
+
candidates.append(normalized)
|
|
1171
|
+
if len(candidates) != 1:
|
|
1172
|
+
raise ReviewToolError(
|
|
1173
|
+
"comment-write-uncertain",
|
|
1174
|
+
"写后无法按 discussion、正文、作者、位置和 head 唯一确认评论;禁止直接重试。",
|
|
1175
|
+
4,
|
|
1176
|
+
)
|
|
1177
|
+
return candidates[0]
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def default_write_receipt_dir() -> Path:
|
|
1181
|
+
configured = os.getenv("MSDEVFLOW_REVIEW_RECEIPT_DIR")
|
|
1182
|
+
if configured:
|
|
1183
|
+
selected = Path(configured).expanduser()
|
|
1184
|
+
if not selected.is_absolute():
|
|
1185
|
+
raise ReviewToolError(
|
|
1186
|
+
"receipt-directory-invalid",
|
|
1187
|
+
"MSDEVFLOW_REVIEW_RECEIPT_DIR 必须是绝对路径或以 ~ 开头。",
|
|
1188
|
+
3,
|
|
1189
|
+
)
|
|
1190
|
+
return selected
|
|
1191
|
+
if os.name == "nt":
|
|
1192
|
+
root = Path(os.getenv("LOCALAPPDATA") or (Path.home() / "AppData" / "Local"))
|
|
1193
|
+
else:
|
|
1194
|
+
root = Path(os.getenv("XDG_STATE_HOME") or (Path.home() / ".local" / "state"))
|
|
1195
|
+
return root / "msdevflow" / "gitcode-review-write-receipts"
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def prepare_write_receipt_dir(directory: Path | None = None) -> Path:
|
|
1199
|
+
selected = (directory or default_write_receipt_dir()).expanduser().resolve()
|
|
1200
|
+
marker = selected / WRITE_RECEIPT_MARKER
|
|
1201
|
+
if selected.exists():
|
|
1202
|
+
if (
|
|
1203
|
+
not selected.is_dir()
|
|
1204
|
+
or selected.is_symlink()
|
|
1205
|
+
or not marker.is_file()
|
|
1206
|
+
or marker.is_symlink()
|
|
1207
|
+
or marker.read_text(encoding="utf-8") != WRITE_RECEIPT_MARKER_CONTENT
|
|
1208
|
+
):
|
|
1209
|
+
raise ReviewToolError(
|
|
1210
|
+
"receipt-directory-invalid",
|
|
1211
|
+
"拒绝使用:write receipt 路径不是 msdevflow 受管目录。",
|
|
1212
|
+
3,
|
|
1213
|
+
)
|
|
1214
|
+
else:
|
|
1215
|
+
selected.mkdir(parents=True, mode=0o700)
|
|
1216
|
+
marker.write_text(WRITE_RECEIPT_MARKER_CONTENT, encoding="utf-8", newline="\n")
|
|
1217
|
+
return selected
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def write_receipt_key(
|
|
1221
|
+
target: PullRequestTarget,
|
|
1222
|
+
reviewer: str,
|
|
1223
|
+
expected_head: str,
|
|
1224
|
+
kind: str,
|
|
1225
|
+
body: str,
|
|
1226
|
+
path: str | None,
|
|
1227
|
+
position: int | None,
|
|
1228
|
+
) -> str:
|
|
1229
|
+
payload = json.dumps({
|
|
1230
|
+
"repository": target.repository,
|
|
1231
|
+
"number": target.number,
|
|
1232
|
+
"reviewer": reviewer,
|
|
1233
|
+
"expected_head": expected_head,
|
|
1234
|
+
"kind": kind,
|
|
1235
|
+
"body_sha256": hashlib.sha256(body.encode("utf-8")).hexdigest(),
|
|
1236
|
+
"path": path,
|
|
1237
|
+
"position": position,
|
|
1238
|
+
}, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
1239
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def write_receipt_path(directory: Path, key: str) -> Path:
|
|
1243
|
+
return directory / f"{key}.json"
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def load_write_receipt(path: Path) -> dict[str, Any] | None:
|
|
1247
|
+
if not path.is_file() or path.is_symlink():
|
|
1248
|
+
return None
|
|
1249
|
+
try:
|
|
1250
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
1251
|
+
except (OSError, ValueError):
|
|
1252
|
+
raise ReviewToolError("write-receipt-invalid", "Review write receipt 已损坏,禁止再次写入。", 4)
|
|
1253
|
+
if not isinstance(payload, dict) or payload.get("tool") != TOOL_NAME:
|
|
1254
|
+
raise ReviewToolError("write-receipt-invalid", "Review write receipt 内容无效,禁止再次写入。", 4)
|
|
1255
|
+
return payload
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def save_write_receipt(path: Path, payload: dict[str, Any]) -> None:
|
|
1259
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=".receipt-", suffix=".json", dir=str(path.parent))
|
|
1260
|
+
os.close(descriptor)
|
|
1261
|
+
temporary = Path(temporary_name)
|
|
1262
|
+
try:
|
|
1263
|
+
write_json(temporary, payload)
|
|
1264
|
+
temporary.replace(path)
|
|
1265
|
+
finally:
|
|
1266
|
+
temporary.unlink(missing_ok=True)
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def validate_write_receipt(
|
|
1270
|
+
receipt: dict[str, Any],
|
|
1271
|
+
target: PullRequestTarget,
|
|
1272
|
+
reviewer: str,
|
|
1273
|
+
expected_head: str,
|
|
1274
|
+
kind: str,
|
|
1275
|
+
body: str,
|
|
1276
|
+
path: str | None,
|
|
1277
|
+
position: int | None,
|
|
1278
|
+
) -> None:
|
|
1279
|
+
expected = {
|
|
1280
|
+
"tool": TOOL_NAME,
|
|
1281
|
+
"version": TOOL_VERSION,
|
|
1282
|
+
"repository": target.repository,
|
|
1283
|
+
"number": target.number,
|
|
1284
|
+
"reviewer": reviewer,
|
|
1285
|
+
"expected_head": expected_head,
|
|
1286
|
+
"kind": kind,
|
|
1287
|
+
"body_sha256": hashlib.sha256(body.encode("utf-8")).hexdigest(),
|
|
1288
|
+
"path": path,
|
|
1289
|
+
"position": position,
|
|
1290
|
+
}
|
|
1291
|
+
if any(receipt.get(key) != value for key, value in expected.items()):
|
|
1292
|
+
raise ReviewToolError("write-receipt-invalid", "Review write receipt 与当前写入事务不匹配。", 4)
|
|
1293
|
+
if "body" in receipt or "token" in receipt:
|
|
1294
|
+
raise ReviewToolError("write-receipt-invalid", "Review write receipt 包含禁止持久化的字段。", 4)
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
def receipt_identity_set(receipt: dict[str, Any]) -> set[tuple[str, str]]:
|
|
1298
|
+
result = set()
|
|
1299
|
+
for item in receipt.get("preexisting", []):
|
|
1300
|
+
if not isinstance(item, list) or len(item) != 2:
|
|
1301
|
+
raise ReviewToolError("write-receipt-invalid", "Review write receipt 的评论集合无效。", 4)
|
|
1302
|
+
result.add((str(item[0]), str(item[1])))
|
|
1303
|
+
return result
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def verify_receipt_comment(
|
|
1307
|
+
client: GitCodeClient,
|
|
1308
|
+
target: PullRequestTarget,
|
|
1309
|
+
comments: list[dict[str, Any]],
|
|
1310
|
+
receipt: dict[str, Any],
|
|
1311
|
+
reviewer: str,
|
|
1312
|
+
body: str,
|
|
1313
|
+
path: str | None,
|
|
1314
|
+
position: int | None,
|
|
1315
|
+
expected_head: str,
|
|
1316
|
+
) -> dict[str, Any]:
|
|
1317
|
+
response_id = str(receipt.get("response_id") or "")
|
|
1318
|
+
return select_written_comment(
|
|
1319
|
+
client,
|
|
1320
|
+
target,
|
|
1321
|
+
comments,
|
|
1322
|
+
receipt_identity_set(receipt),
|
|
1323
|
+
reviewer,
|
|
1324
|
+
body,
|
|
1325
|
+
response_id,
|
|
1326
|
+
path,
|
|
1327
|
+
position,
|
|
1328
|
+
expected_head,
|
|
1329
|
+
)
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
def write_body_file(body: str) -> Path:
|
|
1333
|
+
descriptor, name = tempfile.mkstemp(prefix="msdevflow-gitcode-review-", suffix=".md")
|
|
1334
|
+
os.close(descriptor)
|
|
1335
|
+
path = Path(name)
|
|
1336
|
+
path.write_bytes(body.encode("utf-8"))
|
|
1337
|
+
return path
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def publish_review_comment(
|
|
1341
|
+
client: GitCodeClient,
|
|
1342
|
+
target: PullRequestTarget,
|
|
1343
|
+
expected_head: str,
|
|
1344
|
+
body: str,
|
|
1345
|
+
*,
|
|
1346
|
+
path: str | None = None,
|
|
1347
|
+
position: int | None = None,
|
|
1348
|
+
kind: str,
|
|
1349
|
+
) -> dict[str, Any]:
|
|
1350
|
+
if (path is None) != (position is None):
|
|
1351
|
+
raise ReviewToolError("inline-location-invalid", "--path 与 --position 必须同时提供或同时省略。", 2)
|
|
1352
|
+
if path is not None and (not safe_remote_path(path) or not is_strict_int(position) or position <= 0):
|
|
1353
|
+
raise ReviewToolError("inline-location-invalid", "Finding 的 path/position 无效。", 2)
|
|
1354
|
+
reviewer, _ = ensure_review_write_target(client, target, expected_head)
|
|
1355
|
+
before = read_comments(client, target)
|
|
1356
|
+
if path is not None:
|
|
1357
|
+
existing = find_existing_inline_comment(
|
|
1358
|
+
client, target, before, reviewer, body, path, position, expected_head
|
|
1359
|
+
)
|
|
1360
|
+
if existing:
|
|
1361
|
+
return {
|
|
1362
|
+
"tool": TOOL_NAME,
|
|
1363
|
+
"state": "finding-present",
|
|
1364
|
+
"repository": target.repository,
|
|
1365
|
+
"number": target.number,
|
|
1366
|
+
"reviewer": reviewer,
|
|
1367
|
+
"head_sha": expected_head,
|
|
1368
|
+
"comment": existing,
|
|
1369
|
+
"idempotent": True,
|
|
1370
|
+
"remote_write": False,
|
|
1371
|
+
}
|
|
1372
|
+
receipt_dir = prepare_write_receipt_dir()
|
|
1373
|
+
receipt_file = write_receipt_path(
|
|
1374
|
+
receipt_dir,
|
|
1375
|
+
write_receipt_key(target, reviewer, expected_head, kind, body, path, position),
|
|
1376
|
+
)
|
|
1377
|
+
receipt = load_write_receipt(receipt_file)
|
|
1378
|
+
if receipt:
|
|
1379
|
+
validate_write_receipt(
|
|
1380
|
+
receipt, target, reviewer, expected_head, kind, body, path, position
|
|
1381
|
+
)
|
|
1382
|
+
if receipt.get("stage") == "verified":
|
|
1383
|
+
written = verify_receipt_comment(
|
|
1384
|
+
client, target, before, receipt, reviewer, body, path, position, expected_head
|
|
1385
|
+
)
|
|
1386
|
+
ensure_review_write_target(client, target, expected_head)
|
|
1387
|
+
return {
|
|
1388
|
+
"tool": TOOL_NAME,
|
|
1389
|
+
"state": "finding-present" if kind == "finding" else "review-passed",
|
|
1390
|
+
"repository": target.repository,
|
|
1391
|
+
"number": target.number,
|
|
1392
|
+
"reviewer": reviewer,
|
|
1393
|
+
"head_sha": expected_head,
|
|
1394
|
+
"comment": written,
|
|
1395
|
+
"idempotent": True,
|
|
1396
|
+
"remote_write": False,
|
|
1397
|
+
}
|
|
1398
|
+
if receipt.get("stage") in {"attempting", "write-returned"}:
|
|
1399
|
+
written = verify_receipt_comment(
|
|
1400
|
+
client, target, before, receipt, reviewer, body, path, position, expected_head
|
|
1401
|
+
)
|
|
1402
|
+
ensure_review_write_target(client, target, expected_head)
|
|
1403
|
+
receipt.update({
|
|
1404
|
+
"stage": "verified",
|
|
1405
|
+
"comment_id": written.get("id"),
|
|
1406
|
+
"discussion_id": written.get("discussion_id"),
|
|
1407
|
+
})
|
|
1408
|
+
save_write_receipt(receipt_file, receipt)
|
|
1409
|
+
return {
|
|
1410
|
+
"tool": TOOL_NAME,
|
|
1411
|
+
"state": "finding-published" if kind == "finding" else "review-passed",
|
|
1412
|
+
"repository": target.repository,
|
|
1413
|
+
"number": target.number,
|
|
1414
|
+
"reviewer": reviewer,
|
|
1415
|
+
"head_sha": expected_head,
|
|
1416
|
+
"comment": written,
|
|
1417
|
+
"idempotent": True,
|
|
1418
|
+
"remote_write": False,
|
|
1419
|
+
"recovered_from_receipt": True,
|
|
1420
|
+
}
|
|
1421
|
+
raise ReviewToolError("write-receipt-invalid", "Review write receipt 阶段无效,禁止再次写入。", 4)
|
|
1422
|
+
preexisting = {comment_identity(comment) for comment in before}
|
|
1423
|
+
receipt = {
|
|
1424
|
+
"tool": TOOL_NAME,
|
|
1425
|
+
"version": TOOL_VERSION,
|
|
1426
|
+
"stage": "attempting",
|
|
1427
|
+
"repository": target.repository,
|
|
1428
|
+
"number": target.number,
|
|
1429
|
+
"reviewer": reviewer,
|
|
1430
|
+
"expected_head": expected_head,
|
|
1431
|
+
"kind": kind,
|
|
1432
|
+
"body_sha256": hashlib.sha256(body.encode("utf-8")).hexdigest(),
|
|
1433
|
+
"path": path,
|
|
1434
|
+
"position": position,
|
|
1435
|
+
"preexisting": [list(item) for item in sorted(preexisting)],
|
|
1436
|
+
"response_id": "",
|
|
1437
|
+
}
|
|
1438
|
+
save_write_receipt(receipt_file, receipt)
|
|
1439
|
+
temporary = write_body_file(body)
|
|
1440
|
+
arguments = [
|
|
1441
|
+
"pr", "comment", str(target.number), "--body-file", str(temporary),
|
|
1442
|
+
"-R", target.repository, "--json",
|
|
1443
|
+
]
|
|
1444
|
+
if path is not None:
|
|
1445
|
+
arguments.extend(["--path", path, "--position", str(position)])
|
|
1446
|
+
response: Any = None
|
|
1447
|
+
write_failed = False
|
|
1448
|
+
try:
|
|
1449
|
+
try:
|
|
1450
|
+
response = client.json(arguments, f"publish {kind}")
|
|
1451
|
+
except GitCodeCommandError:
|
|
1452
|
+
write_failed = True
|
|
1453
|
+
finally:
|
|
1454
|
+
temporary.unlink(missing_ok=True)
|
|
1455
|
+
response_id = str(response.get("id") or "") if isinstance(response, dict) else ""
|
|
1456
|
+
receipt.update({"stage": "write-returned", "response_id": response_id})
|
|
1457
|
+
save_write_receipt(receipt_file, receipt)
|
|
1458
|
+
written = select_written_comment(
|
|
1459
|
+
client,
|
|
1460
|
+
target,
|
|
1461
|
+
read_comments(client, target),
|
|
1462
|
+
preexisting,
|
|
1463
|
+
reviewer,
|
|
1464
|
+
body,
|
|
1465
|
+
response_id,
|
|
1466
|
+
path,
|
|
1467
|
+
position,
|
|
1468
|
+
expected_head,
|
|
1469
|
+
)
|
|
1470
|
+
final_payload = client.json(
|
|
1471
|
+
["pr", "view", str(target.number), "-R", target.repository, "--json"],
|
|
1472
|
+
f"re-read PR {target.repository}#{target.number}",
|
|
1473
|
+
)
|
|
1474
|
+
final_pr = pull_request_summary(final_payload, target)
|
|
1475
|
+
if final_pr["head_sha"] != expected_head:
|
|
1476
|
+
raise ReviewToolError(
|
|
1477
|
+
"head-changed-after-write",
|
|
1478
|
+
"评论写入后 PR head 已变化;评论只对应旧 head,禁止报告当前 head 通过。",
|
|
1479
|
+
4,
|
|
1480
|
+
)
|
|
1481
|
+
if kind == "finding" and (written["body"].count(SIGNATURE) != 1):
|
|
1482
|
+
raise ReviewToolError("finding-signature-invalid", "远端 finding 尾签校验失败。", 4)
|
|
1483
|
+
receipt.update({
|
|
1484
|
+
"stage": "verified",
|
|
1485
|
+
"comment_id": written.get("id"),
|
|
1486
|
+
"discussion_id": written.get("discussion_id"),
|
|
1487
|
+
})
|
|
1488
|
+
save_write_receipt(receipt_file, receipt)
|
|
1489
|
+
return {
|
|
1490
|
+
"tool": TOOL_NAME,
|
|
1491
|
+
"state": "finding-published" if kind == "finding" else "review-passed",
|
|
1492
|
+
"repository": target.repository,
|
|
1493
|
+
"number": target.number,
|
|
1494
|
+
"reviewer": reviewer,
|
|
1495
|
+
"head_sha": expected_head,
|
|
1496
|
+
"comment": written,
|
|
1497
|
+
"idempotent": False,
|
|
1498
|
+
"remote_write": True,
|
|
1499
|
+
"recovered_after_write_error": write_failed,
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
def publish_finding(
|
|
1504
|
+
client: GitCodeClient,
|
|
1505
|
+
target: PullRequestTarget,
|
|
1506
|
+
expected_head: str,
|
|
1507
|
+
body_file: Path,
|
|
1508
|
+
path: str | None = None,
|
|
1509
|
+
position: int | None = None,
|
|
1510
|
+
) -> dict[str, Any]:
|
|
1511
|
+
body = validate_finding_body(body_file)
|
|
1512
|
+
return publish_review_comment(
|
|
1513
|
+
client,
|
|
1514
|
+
target,
|
|
1515
|
+
expected_head,
|
|
1516
|
+
body,
|
|
1517
|
+
path=path,
|
|
1518
|
+
position=position,
|
|
1519
|
+
kind="finding",
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def publish_lgtm(
|
|
1524
|
+
client: GitCodeClient,
|
|
1525
|
+
target: PullRequestTarget,
|
|
1526
|
+
expected_head: str,
|
|
1527
|
+
) -> dict[str, Any]:
|
|
1528
|
+
return publish_review_comment(
|
|
1529
|
+
client,
|
|
1530
|
+
target,
|
|
1531
|
+
expected_head,
|
|
1532
|
+
"/lgtm",
|
|
1533
|
+
kind="lgtm",
|
|
1534
|
+
)
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
def cleanup_snapshot(directory: Path) -> dict[str, Any]:
|
|
1538
|
+
selected = directory.expanduser().resolve()
|
|
1539
|
+
marker = selected / SNAPSHOT_MARKER
|
|
1540
|
+
if (
|
|
1541
|
+
not selected.is_dir()
|
|
1542
|
+
or selected.is_symlink()
|
|
1543
|
+
or not selected.name.startswith("review-")
|
|
1544
|
+
or not marker.is_file()
|
|
1545
|
+
or marker.is_symlink()
|
|
1546
|
+
or marker.read_text(encoding="utf-8") != SNAPSHOT_MARKER_CONTENT
|
|
1547
|
+
):
|
|
1548
|
+
raise ReviewToolError(
|
|
1549
|
+
"cleanup-refused",
|
|
1550
|
+
"拒绝删除:目录不是由 gitcode_review.py 创建的受管 snapshot。",
|
|
1551
|
+
4,
|
|
1552
|
+
)
|
|
1553
|
+
shutil.rmtree(selected)
|
|
1554
|
+
return {"tool": TOOL_NAME, "state": "snapshot-removed", "snapshot_dir": str(selected)}
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
def add_gitcode_argument(parser: argparse.ArgumentParser) -> None:
|
|
1558
|
+
parser.add_argument("--gitcode-command", required=True, help="Fixed gitcode or gitcode-npm command")
|
|
1559
|
+
|
|
1560
|
+
|
|
1561
|
+
def add_pr_arguments(parser: argparse.ArgumentParser) -> None:
|
|
1562
|
+
parser.add_argument("--pr", required=True, help="PR URL, owner/repo#number, or numeric PR")
|
|
1563
|
+
parser.add_argument("--repo", help="Canonical owner/repo for a numeric PR")
|
|
1564
|
+
parser.add_argument("--expected-head", required=True, help="Confirmed 40-character PR head SHA")
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1568
|
+
parser = argparse.ArgumentParser(description="Deterministic GitCode code-review transport")
|
|
1569
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
1570
|
+
|
|
1571
|
+
snapshot = subparsers.add_parser("snapshot", help="Collect one or more read-only PR snapshots")
|
|
1572
|
+
add_gitcode_argument(snapshot)
|
|
1573
|
+
snapshot.add_argument("--repo", help="Canonical owner/repo for numeric PR targets")
|
|
1574
|
+
snapshot.add_argument("prs", nargs="+", help="PR URLs, owner/repo#numbers, or numeric PRs")
|
|
1575
|
+
|
|
1576
|
+
context = subparsers.add_parser("context", help="Collect explicitly selected base/head files")
|
|
1577
|
+
add_gitcode_argument(context)
|
|
1578
|
+
add_pr_arguments(context)
|
|
1579
|
+
context.add_argument("--side", choices=("base", "head", "both"), default="both")
|
|
1580
|
+
context.add_argument("paths", nargs="+", help="Exact repository-relative paths")
|
|
1581
|
+
|
|
1582
|
+
finding = subparsers.add_parser("finding", help="Publish and verify one authorized finding")
|
|
1583
|
+
add_gitcode_argument(finding)
|
|
1584
|
+
add_pr_arguments(finding)
|
|
1585
|
+
finding.add_argument("--body-file", required=True, type=Path)
|
|
1586
|
+
finding.add_argument("--path")
|
|
1587
|
+
finding.add_argument("--position", type=int)
|
|
1588
|
+
finding.add_argument("--confirmed-current-head", action="store_true", required=True)
|
|
1589
|
+
|
|
1590
|
+
lgtm = subparsers.add_parser("lgtm", help="Publish and verify exact /lgtm")
|
|
1591
|
+
add_gitcode_argument(lgtm)
|
|
1592
|
+
add_pr_arguments(lgtm)
|
|
1593
|
+
lgtm.add_argument("--confirmed-current-head", action="store_true", required=True)
|
|
1594
|
+
lgtm.add_argument("--confirm-exact-lgtm", action="store_true", required=True)
|
|
1595
|
+
|
|
1596
|
+
cleanup = subparsers.add_parser("cleanup", help="Remove one managed snapshot")
|
|
1597
|
+
cleanup.add_argument("snapshot_dir", type=Path)
|
|
1598
|
+
cleanup.add_argument("--yes", action="store_true", required=True)
|
|
1599
|
+
return parser
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def main() -> int:
|
|
1603
|
+
parser = build_parser()
|
|
1604
|
+
arguments = parser.parse_args()
|
|
1605
|
+
try:
|
|
1606
|
+
if arguments.command == "cleanup":
|
|
1607
|
+
result = cleanup_snapshot(arguments.snapshot_dir)
|
|
1608
|
+
else:
|
|
1609
|
+
client = GitCodeClient(arguments.gitcode_command)
|
|
1610
|
+
if arguments.command == "snapshot":
|
|
1611
|
+
validate_capabilities(client)
|
|
1612
|
+
targets = [parse_pr_target(value, arguments.repo) for value in arguments.prs]
|
|
1613
|
+
result = create_snapshot(client, targets)
|
|
1614
|
+
elif arguments.command == "context":
|
|
1615
|
+
validate_capabilities(client)
|
|
1616
|
+
target = parse_pr_target(arguments.pr, arguments.repo)
|
|
1617
|
+
result = collect_additional_context(
|
|
1618
|
+
client,
|
|
1619
|
+
target,
|
|
1620
|
+
arguments.expected_head,
|
|
1621
|
+
arguments.side,
|
|
1622
|
+
arguments.paths,
|
|
1623
|
+
)
|
|
1624
|
+
elif arguments.command == "finding":
|
|
1625
|
+
inline = arguments.path is not None or arguments.position is not None
|
|
1626
|
+
validate_capabilities(client, write=True, inline=inline)
|
|
1627
|
+
target = parse_pr_target(arguments.pr, arguments.repo)
|
|
1628
|
+
result = publish_finding(
|
|
1629
|
+
client,
|
|
1630
|
+
target,
|
|
1631
|
+
arguments.expected_head,
|
|
1632
|
+
arguments.body_file,
|
|
1633
|
+
arguments.path,
|
|
1634
|
+
arguments.position,
|
|
1635
|
+
)
|
|
1636
|
+
else:
|
|
1637
|
+
validate_capabilities(client, write=True)
|
|
1638
|
+
target = parse_pr_target(arguments.pr, arguments.repo)
|
|
1639
|
+
result = publish_lgtm(client, target, arguments.expected_head)
|
|
1640
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
1641
|
+
return 0
|
|
1642
|
+
except ReviewToolError as error:
|
|
1643
|
+
print(json.dumps({
|
|
1644
|
+
"tool": TOOL_NAME,
|
|
1645
|
+
"state": "blocked",
|
|
1646
|
+
"error": error.error,
|
|
1647
|
+
"message": str(error),
|
|
1648
|
+
}, ensure_ascii=False, indent=2))
|
|
1649
|
+
return error.exit_code
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
if __name__ == "__main__":
|
|
1653
|
+
raise SystemExit(main())
|