@ranger1/dx 0.1.36 → 0.1.38

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.
@@ -0,0 +1,102 @@
1
+ ---
2
+ description: review (GitHub Harvest)
3
+ mode: subagent
4
+ model: openai/gpt-5.2
5
+ temperature: 0.2
6
+ tools:
7
+ write: true
8
+ edit: false
9
+ bash: true
10
+ ---
11
+
12
+ # PR Reviewer (GitHub Harvest)
13
+
14
+ Harvest all GitHub PR review feedback (humans + bots, including Copilot) and normalize into a standard `reviewFile`.
15
+
16
+ ## 输入(prompt 必须包含)
17
+
18
+ - `PR #<number>`
19
+ - `round: <number>`
20
+ - `runId: <string>`(必须透传,禁止自行生成)
21
+ - `contextFile: <filename>`
22
+
23
+ ## Cache 约定(强制)
24
+
25
+ - 缓存目录固定为 `./.cache/`;交接一律传 `./.cache/<file>`(repo 相对路径),禁止 basename-only(如 `foo.md`)。
26
+
27
+ ## 输出(强制)
28
+
29
+ 只输出一行:
30
+
31
+ `reviewFile: ./.cache/<file>.md`
32
+
33
+ ## reviewFile 格式(强制)
34
+
35
+ ```md
36
+ # Review (GHR)
37
+
38
+ PR: <PR_NUMBER>
39
+ Round: <ROUND>
40
+
41
+ ## Summary
42
+
43
+ P0: <n>
44
+ P1: <n>
45
+ P2: <n>
46
+ P3: <n>
47
+
48
+ ## Findings
49
+
50
+ - id: GHR-RC-2752827557
51
+ priority: P1
52
+ category: quality|performance|security|architecture
53
+ file: <path>
54
+ line: <number|null>
55
+ title: <short>
56
+ description: <single-line text>
57
+ suggestion: <single-line text>
58
+ ```
59
+
60
+ ## ID 规则(强制)
61
+
62
+ - Inline 评审(discussion_r...):`GHR-RC-<databaseId>`(databaseId 可映射到 `#discussion_r<databaseId>`)
63
+ - PR Review 总评:`GHR-RV-<reviewId>`
64
+ - PR 普通评论:`GHR-IC-<issueCommentId>`
65
+
66
+ ## 执行步骤(强制)
67
+
68
+ 1) Harvest(确定性)
69
+
70
+ - 调用脚本生成 raw JSON:
71
+
72
+ ```bash
73
+ python3 ~/.opencode/agents/gh_review_harvest.py \
74
+ --pr <PR_NUMBER> \
75
+ --round <ROUND> \
76
+ --run-id <RUN_ID>
77
+ ```
78
+
79
+ - 脚本 stdout 会输出一行 JSON:`{"rawFile":"./.cache/...json"}`,从中取 `rawFile`。
80
+
81
+ 2) Normalize(LLM 分类)
82
+
83
+ - 读取 `rawFile`(JSON)后,提取“建议/问题”并生成 findings:
84
+ - 覆盖 humans + bots(不做作者白名单)。
85
+ - 忽略纯审批/无内容:如 `LGTM`、`Looks good`、`Approved` 等。
86
+ - 分类规则(大致):
87
+ - P0: 明确安全漏洞/数据泄漏/资金损失/远程执行
88
+ - P1: 逻辑 bug/权限绕过/会导致线上错误
89
+ - P2: 潜在 bug/鲁棒性/边界条件/可维护性重大问题
90
+ - P3: 风格/命名/小优化/可选建议
91
+ - `category` 只能取:quality|performance|security|architecture
92
+
93
+ 3) 写入 reviewFile
94
+
95
+ - 文件名固定:`./.cache/review-GHR-pr<PR_NUMBER>-r<ROUND>-<RUN_ID>.md`
96
+ - 重要:`title/description/suggestion` 必须是单行;原文有换行时用 `\\n` 转义。
97
+
98
+ ## 禁止事项(强制)
99
+
100
+ - ⛔ 不发布 GitHub 评论(不调用 `gh pr comment/review`)
101
+ - ⛔ 不修改代码(只输出 reviewFile)
102
+ - ⛔ 不生成/伪造 runId
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # Deterministic GitHub PR review harvester.
4
+ #
5
+ # - Fetches inline review threads via GraphQL (reviewThreads) with pagination.
6
+ # - Fetches PR reviews and PR issue comments via REST (gh api) with pagination.
7
+ # - Writes a raw JSON file into project cache: ./.cache/
8
+ # - Prints exactly one JSON object to stdout: {"rawFile":"./.cache/...json"}
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+
19
+ MARKER_SUBSTR = "<!-- pr-review-loop-marker"
20
+
21
+ def _repo_root():
22
+ try:
23
+ p = subprocess.run(
24
+ ["git", "rev-parse", "--show-toplevel"],
25
+ stdout=subprocess.PIPE,
26
+ stderr=subprocess.DEVNULL,
27
+ text=True,
28
+ )
29
+ out = (p.stdout or "").strip()
30
+ if p.returncode == 0 and out:
31
+ return Path(out)
32
+ except Exception:
33
+ pass
34
+ return Path.cwd()
35
+
36
+
37
+ def _cache_dir(repo_root):
38
+ return (repo_root / ".cache").resolve()
39
+
40
+
41
+ def _repo_relpath(repo_root, p):
42
+ try:
43
+ rel = p.resolve().relative_to(repo_root.resolve())
44
+ return "./" + rel.as_posix()
45
+ except Exception:
46
+ return os.path.basename(str(p))
47
+
48
+
49
+ REPO_ROOT = _repo_root()
50
+ CACHE_DIR = _cache_dir(REPO_ROOT)
51
+
52
+
53
+ def _json_out(obj):
54
+ sys.stdout.write(json.dumps(obj, ensure_ascii=True))
55
+ sys.stdout.write("\n")
56
+
57
+
58
+ def _run_capture(cmd):
59
+ try:
60
+ p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
61
+ return p.returncode, p.stdout, p.stderr
62
+ except FileNotFoundError as e:
63
+ return 127, "", str(e)
64
+
65
+
66
+ def _has_loop_marker(text):
67
+ if not text:
68
+ return False
69
+ try:
70
+ return MARKER_SUBSTR in str(text)
71
+ except Exception:
72
+ return False
73
+
74
+
75
+ def _require_gh_auth():
76
+ rc, out, err = _run_capture(["gh", "auth", "status"])
77
+ if rc == 127:
78
+ return False, "GH_CLI_NOT_FOUND", "gh not found in PATH"
79
+ if rc != 0:
80
+ detail = (err or out or "").strip()
81
+ if len(detail) > 4000:
82
+ detail = detail[-4000:]
83
+ return False, "GH_NOT_AUTHENTICATED", detail
84
+ return True, None, None
85
+
86
+
87
+ def _resolve_owner_repo(explicit_repo):
88
+ if explicit_repo:
89
+ s = str(explicit_repo).strip()
90
+ if s and "/" in s:
91
+ return s
92
+ rc, out, _ = _run_capture(["gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"])
93
+ owner_repo = out.strip() if rc == 0 else ""
94
+ return owner_repo or None
95
+
96
+
97
+ def _gh_api_json(args):
98
+ rc, out, err = _run_capture(["gh", "api"] + args)
99
+ if rc != 0:
100
+ raise RuntimeError(f"GH_API_FAILED: {(err or out or '').strip()}")
101
+ try:
102
+ return json.loads(out or "null")
103
+ except Exception:
104
+ raise RuntimeError("GH_API_JSON_PARSE_FAILED")
105
+
106
+
107
+ def _gh_api_graphql(query, variables):
108
+ cmd = ["gh", "api", "graphql", "-f", f"query={query}"]
109
+ for k, v in (variables or {}).items():
110
+ if isinstance(v, int):
111
+ cmd.extend(["-F", f"{k}={v}"])
112
+ elif v is None:
113
+ cmd.extend(["-f", f"{k}="])
114
+ else:
115
+ cmd.extend(["-f", f"{k}={v}"])
116
+
117
+ rc, out, err = _run_capture(cmd)
118
+ if rc != 0:
119
+ raise RuntimeError(f"GH_GRAPHQL_FAILED: {(err or out or '').strip()}")
120
+ try:
121
+ return json.loads(out or "null")
122
+ except Exception:
123
+ raise RuntimeError("GH_GRAPHQL_JSON_PARSE_FAILED")
124
+
125
+
126
+ def _flatten_threads(gql_data):
127
+ threads = []
128
+ pr = (((gql_data or {}).get("data") or {}).get("repository") or {}).get("pullRequest") or {}
129
+ conn = pr.get("reviewThreads") or {}
130
+ nodes = conn.get("nodes") or []
131
+ for t in nodes:
132
+ is_resolved = bool((t or {}).get("isResolved"))
133
+ is_outdated = bool((t or {}).get("isOutdated"))
134
+ if is_resolved or is_outdated:
135
+ continue
136
+ comments_conn = (t or {}).get("comments") or {}
137
+ comments_nodes = comments_conn.get("nodes") or []
138
+ comments = []
139
+ for c in comments_nodes:
140
+ body = (c or {}).get("body") or ""
141
+ body_text = (c or {}).get("bodyText") or ""
142
+ if _has_loop_marker(body) or _has_loop_marker(body_text):
143
+ continue
144
+ author = (c or {}).get("author") or {}
145
+ comments.append(
146
+ {
147
+ "id": (c or {}).get("id"),
148
+ "databaseId": (c or {}).get("databaseId"),
149
+ "url": (c or {}).get("url"),
150
+ "author": {
151
+ "login": author.get("login"),
152
+ "type": author.get("__typename"),
153
+ },
154
+ "body": body,
155
+ "bodyText": body_text,
156
+ "createdAt": (c or {}).get("createdAt"),
157
+ "updatedAt": (c or {}).get("updatedAt"),
158
+ }
159
+ )
160
+
161
+ if not comments:
162
+ continue
163
+ threads.append(
164
+ {
165
+ "id": (t or {}).get("id"),
166
+ "isResolved": False,
167
+ "isOutdated": False,
168
+ "path": (t or {}).get("path"),
169
+ "line": (t or {}).get("line"),
170
+ "originalLine": (t or {}).get("originalLine"),
171
+ "startLine": (t or {}).get("startLine"),
172
+ "originalStartLine": (t or {}).get("originalStartLine"),
173
+ "comments": comments,
174
+ }
175
+ )
176
+
177
+ page_info = conn.get("pageInfo") or {}
178
+ return threads, {
179
+ "hasNextPage": bool(page_info.get("hasNextPage")),
180
+ "endCursor": page_info.get("endCursor"),
181
+ }
182
+
183
+
184
+ def _fetch_all_review_threads(owner, repo, pr_number):
185
+ query = (
186
+ "query($owner:String!,$repo:String!,$prNumber:Int!,$after:String){"
187
+ "repository(owner:$owner,name:$repo){"
188
+ "pullRequest(number:$prNumber){"
189
+ "reviewThreads(first:100,after:$after){"
190
+ "pageInfo{hasNextPage endCursor}"
191
+ "nodes{"
192
+ "id isResolved isOutdated path line originalLine startLine originalStartLine "
193
+ "comments(first:100){nodes{"
194
+ "id databaseId url body bodyText createdAt updatedAt author{login __typename}"
195
+ "}}"
196
+ "}"
197
+ "}"
198
+ "}"
199
+ "}"
200
+ "}"
201
+ )
202
+
203
+ after = None
204
+ all_threads = []
205
+ while True:
206
+ data = _gh_api_graphql(query, {"owner": owner, "repo": repo, "prNumber": pr_number, "after": after})
207
+ threads, page = _flatten_threads(data)
208
+ all_threads.extend(threads)
209
+ if not page.get("hasNextPage"):
210
+ break
211
+ after = page.get("endCursor")
212
+ if not after:
213
+ break
214
+ return all_threads
215
+
216
+
217
+ def main(argv):
218
+ class _ArgParser(argparse.ArgumentParser):
219
+ def error(self, message):
220
+ raise ValueError(message)
221
+
222
+ parser = _ArgParser(add_help=False)
223
+ parser.add_argument("--pr", type=int, required=True)
224
+ parser.add_argument("--round", type=int, default=1)
225
+ parser.add_argument("--run-id", required=True)
226
+ parser.add_argument("--repo")
227
+
228
+ try:
229
+ args = parser.parse_args(argv)
230
+ except ValueError:
231
+ _json_out({"error": "INVALID_ARGS"})
232
+ return 2
233
+
234
+ ok, code, detail = _require_gh_auth()
235
+ if not ok:
236
+ _json_out({"error": code, "detail": detail})
237
+ return 1
238
+
239
+ owner_repo = _resolve_owner_repo(args.repo)
240
+ if not owner_repo:
241
+ _json_out({"error": "REPO_NOT_FOUND"})
242
+ return 1
243
+ if "/" not in owner_repo:
244
+ _json_out({"error": "INVALID_REPO"})
245
+ return 1
246
+
247
+ owner, repo = owner_repo.split("/", 1)
248
+ pr_number = int(args.pr)
249
+ round_num = int(args.round)
250
+ run_id = str(args.run_id).strip()
251
+ if not run_id:
252
+ _json_out({"error": "MISSING_RUN_ID"})
253
+ return 1
254
+
255
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
256
+ raw_basename = f"gh-review-raw-pr{pr_number}-r{round_num}-{run_id}.json"
257
+ raw_path = CACHE_DIR / raw_basename
258
+
259
+ try:
260
+ threads = _fetch_all_review_threads(owner, repo, pr_number)
261
+
262
+ reviews = _gh_api_json([f"repos/{owner_repo}/pulls/{pr_number}/reviews", "--paginate"])
263
+ issue_comments = _gh_api_json([f"repos/{owner_repo}/issues/{pr_number}/comments", "--paginate"])
264
+
265
+ if isinstance(reviews, list):
266
+ reviews = [r for r in reviews if not _has_loop_marker((r or {}).get("body") or "")]
267
+ if isinstance(issue_comments, list):
268
+ issue_comments = [c for c in issue_comments if not _has_loop_marker((c or {}).get("body") or "")]
269
+
270
+ now = datetime.now(timezone.utc).isoformat()
271
+ payload = {
272
+ "repo": owner_repo,
273
+ "pr": pr_number,
274
+ "round": round_num,
275
+ "runId": run_id,
276
+ "generatedAt": now,
277
+ "reviewThreads": threads,
278
+ "reviews": reviews if isinstance(reviews, list) else [],
279
+ "issueComments": issue_comments if isinstance(issue_comments, list) else [],
280
+ }
281
+
282
+ raw_path.write_text(json.dumps(payload, ensure_ascii=True), encoding="utf-8", newline="\n")
283
+ except Exception as e:
284
+ _json_out({"error": "HARVEST_FAILED", "detail": str(e)[:800]})
285
+ return 1
286
+
287
+ _json_out({"rawFile": _repo_relpath(REPO_ROOT, raw_path)})
288
+ return 0
289
+
290
+
291
+ if __name__ == "__main__":
292
+ raise SystemExit(main(sys.argv[1:]))
@@ -21,7 +21,7 @@ tools:
21
21
  - `round: <number>`
22
22
  - `runId: <string>`
23
23
  - `contextFile: <path>`(例如:`./.cache/pr-context-...md`)
24
- - `reviewFile: <path>`(三行,分别对应 CDX/CLD/GMN,例如:`./.cache/review-...md`)
24
+ - `reviewFile: <path>`(多行,1+ 条;例如:`./.cache/review-...md`)
25
25
 
26
26
  ### 模式 B:发布修复评论(基于 fixReportFile)
27
27
 
@@ -53,7 +53,7 @@ runId: abcdef123456
53
53
 
54
54
  ## 重复分组(仅作为脚本入参)
55
55
 
56
- 你需要基于 3 份 `reviewFile` 内容判断重复 finding 分组,生成**一行 JSON**(不要代码块、不要解释文字、不要换行)。
56
+ 你需要基于所有 `reviewFile` 内容判断重复 finding 分组,生成**一行 JSON**(不要代码块、不要解释文字、不要换行)。
57
57
 
58
58
  注意:这行 JSON **不是你的最终输出**,它只用于生成 `--duplicate-groups-b64` 传给脚本。
59
59
 
@@ -22,6 +22,7 @@ agent: sisyphus
22
22
  - `codex-reviewer`
23
23
  - `claude-reviewer`
24
24
  - `gemini-reviewer`
25
+ - `gh-thread-reviewer`
25
26
  - `pr-review-aggregate`
26
27
  - `pr-fix`
27
28
 
@@ -57,16 +58,16 @@ agent: sisyphus
57
58
  - 取出:`contextFile`、`runId`、`headOid`(如有)
58
59
  - **CRITICAL**: 必须等待此 Task 成功完成并获取到 `contextFile` 后,才能进入 Step 2
59
60
 
60
- 2. Task(并行): `codex-reviewer` + `claude-reviewer` + `gemini-reviewer` **(依赖 Step 1 的 contextFile)**
61
+ 2. Task(并行): `codex-reviewer` + `claude-reviewer` + `gemini-reviewer` + `gh-thread-reviewer` **(依赖 Step 1 的 contextFile)**
61
62
 
62
- - **DEPENDENCY**: 这三个 reviewers 依赖 Step 1 返回的 `contextFile`,因此**必须等 Step 1 完成后才能并行启动**
63
+ - **DEPENDENCY**: 这些 reviewers 依赖 Step 1 返回的 `contextFile`,因此**必须等 Step 1 完成后才能并行启动**
63
64
  - 每个 reviewer prompt 必须包含:
64
65
  - `PR #{{PR_NUMBER}}`
65
66
  - `round: <ROUND>`
66
67
  - `runId: <RUN_ID>`(来自 Step 1 的输出,必须透传,禁止自行生成)
67
68
  - `contextFile: ./.cache/<file>.md`(来自 Step 1 的输出)
68
69
  - reviewer 默认读 `contextFile`;必要时允许用 `git/gh` 只读命令拿 diff
69
- - 忽略问题:1.格式化代码引起的噪音 2.已经lint检查以外的格式问题
70
+ - 忽略问题:1.格式化代码引起的噪音 2.已经lint检查以外的格式问题 3.忽略单元测试不足的问题
70
71
  - 特别关注: 逻辑、安全、性能、可维护性
71
72
  - 同时要注意 pr 前面轮次的 修复和讨论,对于已经拒绝、已修复的问题不要反复的提出
72
73
  - 同时也要注意fix的过程中有没有引入新的问题。
@@ -74,7 +75,7 @@ agent: sisyphus
74
75
 
75
76
  3. Task: `pr-review-aggregate`
76
77
 
77
- - prompt 必须包含:`PR #{{PR_NUMBER}}`、`round: <ROUND>`、`runId: <RUN_ID>`、`contextFile: ./.cache/<file>.md`、三条 `reviewFile: ./.cache/<file>.md`
78
+ - prompt 必须包含:`PR #{{PR_NUMBER}}`、`round: <ROUND>`、`runId: <RUN_ID>`、`contextFile: ./.cache/<file>.md`、以及 1+ 条 `reviewFile: ./.cache/<file>.md`
78
79
  - 输出:`{"stop":true}` 或 `{"stop":false,"fixFile":"..."}`
79
80
  - 若 `stop=true`:本轮结束并退出循环
80
81
  - **唯一性约束**: 每轮只能发布一次 Review Summary;脚本内置幂等检查,重复调用不会重复发布
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ranger1/dx",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {