@ranger1/dx 0.1.47 → 0.1.49
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/@opencode/agents/__pycache__/pr_context.cpython-314.pyc +0 -0
- package/@opencode/agents/__pycache__/pr_precheck.cpython-314.pyc +0 -0
- package/@opencode/agents/__pycache__/pr_review_aggregate.cpython-314.pyc +0 -0
- package/@opencode/agents/__pycache__/test_pr_review_aggregate.cpython-314-pytest-9.0.2.pyc +0 -0
- package/@opencode/agents/__pycache__/test_pr_review_aggregate.cpython-314.pyc +0 -0
- package/@opencode/agents/claude-reviewer.md +6 -2
- package/@opencode/agents/codex-reviewer.md +6 -2
- package/@opencode/agents/gemini-reviewer.md +6 -2
- package/@opencode/agents/gh-thread-reviewer.md +6 -1
- package/@opencode/agents/pr-context.md +12 -0
- package/@opencode/agents/pr-fix.md +6 -1
- package/@opencode/agents/pr-precheck.md +4 -2
- package/@opencode/agents/pr-review-aggregate.md +18 -10
- package/@opencode/agents/pr_context.py +35 -19
- package/@opencode/agents/pr_precheck.py +210 -42
- package/@opencode/agents/test_pr_review_aggregate.py +234 -33
- package/@opencode/commands/pr-review-loop.md +61 -11
- package/lib/cli/args.js +21 -2
- package/lib/cli/commands/deploy.js +31 -2
- package/lib/cli/dx-cli.js +16 -6
- package/lib/cli/flags.js +6 -0
- package/lib/cli/help.js +13 -4
- package/lib/telegram-webhook.js +210 -23
- package/lib/vercel-deploy.js +19 -9
- package/package.json +1 -1
|
@@ -8,21 +8,47 @@ Tests cover:
|
|
|
8
8
|
3. Edge cases: empty input, malformed data, cross-reviewer matching
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
import
|
|
11
|
+
import importlib.util
|
|
12
|
+
import json
|
|
13
|
+
import subprocess
|
|
14
|
+
from collections.abc import Mapping, Sequence
|
|
14
15
|
from pathlib import Path
|
|
16
|
+
from types import SimpleNamespace
|
|
17
|
+
from typing import Callable, cast
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_pr_review_aggregate_module():
|
|
23
|
+
module_path = Path(__file__).with_name("pr_review_aggregate.py")
|
|
24
|
+
spec = importlib.util.spec_from_file_location("pr_review_aggregate", module_path)
|
|
25
|
+
if spec is None or spec.loader is None:
|
|
26
|
+
raise RuntimeError(f"Failed to load module spec: {module_path}")
|
|
27
|
+
module = importlib.util.module_from_spec(spec)
|
|
28
|
+
spec.loader.exec_module(module)
|
|
29
|
+
return module
|
|
15
30
|
|
|
16
|
-
# Add parent directory to path for importing pr_review_aggregate
|
|
17
|
-
sys.path.insert(0, str(Path(__file__).parent))
|
|
18
31
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
32
|
+
_pr_review_aggregate = _load_pr_review_aggregate_module()
|
|
33
|
+
|
|
34
|
+
_parse_decision_log = cast(Callable[[str], list[dict[str, object]]], getattr(_pr_review_aggregate, "_parse_decision_log"))
|
|
35
|
+
_filter_by_decision_log = cast(
|
|
36
|
+
Callable[[Sequence[Mapping[str, object]], Sequence[Mapping[str, object]], list[list[str]]], list[dict[str, object]]],
|
|
37
|
+
getattr(_pr_review_aggregate, "_filter_by_decision_log"),
|
|
38
|
+
)
|
|
39
|
+
_parse_escalation_groups_json = cast(
|
|
40
|
+
Callable[[str], list[list[str]]],
|
|
41
|
+
getattr(_pr_review_aggregate, "_parse_escalation_groups_json"),
|
|
42
|
+
)
|
|
43
|
+
_parse_escalation_groups_b64 = cast(
|
|
44
|
+
Callable[[str], list[list[str]]],
|
|
45
|
+
getattr(_pr_review_aggregate, "_parse_escalation_groups_b64"),
|
|
46
|
+
)
|
|
47
|
+
_check_existing_comment = cast(
|
|
48
|
+
Callable[[int, str, int, str], bool],
|
|
49
|
+
getattr(_pr_review_aggregate, "_check_existing_comment"),
|
|
25
50
|
)
|
|
51
|
+
_MARKER = cast(str, getattr(_pr_review_aggregate, "MARKER"))
|
|
26
52
|
|
|
27
53
|
|
|
28
54
|
# ============================================================
|
|
@@ -30,13 +56,13 @@ from pr_review_aggregate import (
|
|
|
30
56
|
# ============================================================
|
|
31
57
|
|
|
32
58
|
@pytest.fixture
|
|
33
|
-
def empty_decision_log():
|
|
59
|
+
def empty_decision_log() -> str:
|
|
34
60
|
"""Empty decision log markdown."""
|
|
35
61
|
return ""
|
|
36
62
|
|
|
37
63
|
|
|
38
64
|
@pytest.fixture
|
|
39
|
-
def valid_decision_log():
|
|
65
|
+
def valid_decision_log() -> str:
|
|
40
66
|
"""Valid decision log with Fixed and Rejected entries."""
|
|
41
67
|
return """# Decision Log
|
|
42
68
|
|
|
@@ -46,20 +72,24 @@ PR: 123
|
|
|
46
72
|
|
|
47
73
|
### Fixed
|
|
48
74
|
- id: CDX-001
|
|
75
|
+
file: apps/backend/src/api.ts
|
|
49
76
|
commit: abc123
|
|
50
77
|
essence: JSON.parse 未捕获异常
|
|
51
78
|
|
|
52
79
|
- id: GMN-002
|
|
80
|
+
file: apps/front/src/ErrorBoundary.tsx
|
|
53
81
|
commit: def456
|
|
54
82
|
essence: 缺少错误边界处理
|
|
55
83
|
|
|
56
84
|
### Rejected
|
|
57
85
|
- id: GMN-004
|
|
86
|
+
file: apps/front/src/Component.tsx
|
|
58
87
|
priority: P2
|
|
59
88
|
reason: 需要产品决策,超出 PR 范围
|
|
60
89
|
essence: 组件拆分建议
|
|
61
90
|
|
|
62
91
|
- id: CLD-003
|
|
92
|
+
file: apps/backend/src/db.ts
|
|
63
93
|
priority: P3
|
|
64
94
|
reason: 性能优化非当前优先级
|
|
65
95
|
essence: 批量查询优化
|
|
@@ -67,7 +97,38 @@ PR: 123
|
|
|
67
97
|
|
|
68
98
|
|
|
69
99
|
@pytest.fixture
|
|
70
|
-
def
|
|
100
|
+
def valid_decision_log_legacy_no_file() -> str:
|
|
101
|
+
"""Legacy decision log fixture without the file: field (backward compat)."""
|
|
102
|
+
return """# Decision Log
|
|
103
|
+
|
|
104
|
+
PR: 123
|
|
105
|
+
|
|
106
|
+
## Round 1
|
|
107
|
+
|
|
108
|
+
### Fixed
|
|
109
|
+
- id: CDX-001
|
|
110
|
+
commit: abc123
|
|
111
|
+
essence: JSON.parse 未捕获异常
|
|
112
|
+
|
|
113
|
+
- id: GMN-002
|
|
114
|
+
commit: def456
|
|
115
|
+
essence: 缺少错误边界处理
|
|
116
|
+
|
|
117
|
+
### Rejected
|
|
118
|
+
- id: GMN-004
|
|
119
|
+
priority: P2
|
|
120
|
+
reason: 需要产品决策,超出 PR 范围
|
|
121
|
+
essence: 组件拆分建议
|
|
122
|
+
|
|
123
|
+
- id: CLD-003
|
|
124
|
+
priority: P3
|
|
125
|
+
reason: 性能优化非当前优先级
|
|
126
|
+
essence: 批量查询优化
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.fixture
|
|
131
|
+
def malformed_decision_log() -> str:
|
|
71
132
|
"""Malformed decision log with missing fields and bad formatting."""
|
|
72
133
|
return """# Decision Log
|
|
73
134
|
|
|
@@ -90,7 +151,7 @@ Some random text that should be ignored
|
|
|
90
151
|
|
|
91
152
|
|
|
92
153
|
@pytest.fixture
|
|
93
|
-
def sample_findings():
|
|
154
|
+
def sample_findings() -> list[dict[str, object]]:
|
|
94
155
|
"""Sample findings list for filter tests."""
|
|
95
156
|
return [
|
|
96
157
|
{
|
|
@@ -137,7 +198,7 @@ def sample_findings():
|
|
|
137
198
|
|
|
138
199
|
|
|
139
200
|
@pytest.fixture
|
|
140
|
-
def prior_decisions():
|
|
201
|
+
def prior_decisions() -> list[dict[str, object]]:
|
|
141
202
|
"""Sample prior decisions from _parse_decision_log."""
|
|
142
203
|
return [
|
|
143
204
|
{
|
|
@@ -160,7 +221,7 @@ def prior_decisions():
|
|
|
160
221
|
# Test: _parse_decision_log() - Empty Input
|
|
161
222
|
# ============================================================
|
|
162
223
|
|
|
163
|
-
def test_parse_decision_log_empty(empty_decision_log):
|
|
224
|
+
def test_parse_decision_log_empty(empty_decision_log: str) -> None:
|
|
164
225
|
"""
|
|
165
226
|
Test that empty decision log returns empty list.
|
|
166
227
|
|
|
@@ -177,7 +238,7 @@ def test_parse_decision_log_empty(empty_decision_log):
|
|
|
177
238
|
# Test: _parse_decision_log() - Valid Input
|
|
178
239
|
# ============================================================
|
|
179
240
|
|
|
180
|
-
def test_parse_decision_log_valid(valid_decision_log):
|
|
241
|
+
def test_parse_decision_log_valid(valid_decision_log: str) -> None:
|
|
181
242
|
"""
|
|
182
243
|
Test that valid decision log is parsed into structured data.
|
|
183
244
|
|
|
@@ -194,6 +255,7 @@ def test_parse_decision_log_valid(valid_decision_log):
|
|
|
194
255
|
fixed_1 = result[0]
|
|
195
256
|
assert fixed_1["id"] == "CDX-001"
|
|
196
257
|
assert fixed_1["status"] == "fixed"
|
|
258
|
+
assert fixed_1["file"] == "apps/backend/src/api.ts"
|
|
197
259
|
assert fixed_1["commit"] == "abc123"
|
|
198
260
|
assert fixed_1["essence"] == "JSON.parse 未捕获异常"
|
|
199
261
|
|
|
@@ -201,6 +263,7 @@ def test_parse_decision_log_valid(valid_decision_log):
|
|
|
201
263
|
fixed_2 = result[1]
|
|
202
264
|
assert fixed_2["id"] == "GMN-002"
|
|
203
265
|
assert fixed_2["status"] == "fixed"
|
|
266
|
+
assert fixed_2["file"] == "apps/front/src/ErrorBoundary.tsx"
|
|
204
267
|
assert fixed_2["commit"] == "def456"
|
|
205
268
|
assert fixed_2["essence"] == "缺少错误边界处理"
|
|
206
269
|
|
|
@@ -208,6 +271,7 @@ def test_parse_decision_log_valid(valid_decision_log):
|
|
|
208
271
|
rejected_1 = result[2]
|
|
209
272
|
assert rejected_1["id"] == "GMN-004"
|
|
210
273
|
assert rejected_1["status"] == "rejected"
|
|
274
|
+
assert rejected_1["file"] == "apps/front/src/Component.tsx"
|
|
211
275
|
assert rejected_1["priority"] == "P2"
|
|
212
276
|
assert rejected_1["reason"] == "需要产品决策,超出 PR 范围"
|
|
213
277
|
assert rejected_1["essence"] == "组件拆分建议"
|
|
@@ -216,14 +280,31 @@ def test_parse_decision_log_valid(valid_decision_log):
|
|
|
216
280
|
rejected_2 = result[3]
|
|
217
281
|
assert rejected_2["id"] == "CLD-003"
|
|
218
282
|
assert rejected_2["status"] == "rejected"
|
|
283
|
+
assert rejected_2["file"] == "apps/backend/src/db.ts"
|
|
219
284
|
assert rejected_2["priority"] == "P3"
|
|
220
285
|
|
|
221
286
|
|
|
287
|
+
def test_parse_decision_log_legacy_without_file(valid_decision_log_legacy_no_file: str) -> None:
|
|
288
|
+
"""Decision log entries without file: should still parse (backward compat)."""
|
|
289
|
+
result = _parse_decision_log(valid_decision_log_legacy_no_file)
|
|
290
|
+
|
|
291
|
+
# Should have 4 entries (2 Fixed, 2 Rejected)
|
|
292
|
+
assert len(result) == 4
|
|
293
|
+
|
|
294
|
+
# Basic shape should still be present
|
|
295
|
+
for entry in result:
|
|
296
|
+
assert "id" in entry
|
|
297
|
+
assert "status" in entry
|
|
298
|
+
|
|
299
|
+
# And file should be optional
|
|
300
|
+
assert all(("file" not in e) or (e["file"] in (None, "")) for e in result)
|
|
301
|
+
|
|
302
|
+
|
|
222
303
|
# ============================================================
|
|
223
304
|
# Test: _parse_decision_log() - Malformed Input
|
|
224
305
|
# ============================================================
|
|
225
306
|
|
|
226
|
-
def test_parse_decision_log_malformed(malformed_decision_log):
|
|
307
|
+
def test_parse_decision_log_malformed(malformed_decision_log: str) -> None:
|
|
227
308
|
"""
|
|
228
309
|
Test that malformed decision log degrades gracefully.
|
|
229
310
|
|
|
@@ -247,7 +328,7 @@ def test_parse_decision_log_malformed(malformed_decision_log):
|
|
|
247
328
|
# Test: _filter_by_decision_log() - Fixed Issues
|
|
248
329
|
# ============================================================
|
|
249
330
|
|
|
250
|
-
def test_filter_fixed_issues(sample_findings, prior_decisions):
|
|
331
|
+
def test_filter_fixed_issues(sample_findings: list[dict[str, object]], prior_decisions: list[dict[str, object]]) -> None:
|
|
251
332
|
"""
|
|
252
333
|
Test that findings matching Fixed decisions are filtered out.
|
|
253
334
|
|
|
@@ -255,7 +336,7 @@ def test_filter_fixed_issues(sample_findings, prior_decisions):
|
|
|
255
336
|
When: _filter_by_decision_log() is called with empty escalation_groups
|
|
256
337
|
Then: CDX-001 is filtered out
|
|
257
338
|
"""
|
|
258
|
-
escalation_groups = []
|
|
339
|
+
escalation_groups: list[list[str]] = []
|
|
259
340
|
|
|
260
341
|
result = _filter_by_decision_log(sample_findings, prior_decisions, escalation_groups)
|
|
261
342
|
|
|
@@ -271,7 +352,7 @@ def test_filter_fixed_issues(sample_findings, prior_decisions):
|
|
|
271
352
|
# Test: _filter_by_decision_log() - Rejected Without Escalation
|
|
272
353
|
# ============================================================
|
|
273
354
|
|
|
274
|
-
def test_filter_rejected_without_escalation(sample_findings, prior_decisions):
|
|
355
|
+
def test_filter_rejected_without_escalation(sample_findings: list[dict[str, object]], prior_decisions: list[dict[str, object]]) -> None:
|
|
275
356
|
"""
|
|
276
357
|
Test that findings matching Rejected decisions are filtered out when NOT in escalation_groups.
|
|
277
358
|
|
|
@@ -280,7 +361,7 @@ def test_filter_rejected_without_escalation(sample_findings, prior_decisions):
|
|
|
280
361
|
When: _filter_by_decision_log() is called
|
|
281
362
|
Then: GMN-004 is filtered out
|
|
282
363
|
"""
|
|
283
|
-
escalation_groups = []
|
|
364
|
+
escalation_groups: list[list[str]] = []
|
|
284
365
|
|
|
285
366
|
result = _filter_by_decision_log(sample_findings, prior_decisions, escalation_groups)
|
|
286
367
|
|
|
@@ -296,7 +377,7 @@ def test_filter_rejected_without_escalation(sample_findings, prior_decisions):
|
|
|
296
377
|
# Test: _filter_by_decision_log() - Rejected With Escalation
|
|
297
378
|
# ============================================================
|
|
298
379
|
|
|
299
|
-
def test_filter_rejected_with_escalation(sample_findings, prior_decisions):
|
|
380
|
+
def test_filter_rejected_with_escalation(sample_findings: list[dict[str, object]], prior_decisions: list[dict[str, object]]) -> None:
|
|
300
381
|
"""
|
|
301
382
|
Test that findings matching Rejected decisions are kept when in escalation_groups.
|
|
302
383
|
|
|
@@ -322,7 +403,7 @@ def test_filter_rejected_with_escalation(sample_findings, prior_decisions):
|
|
|
322
403
|
# Test: _filter_by_decision_log() - Cross-Reviewer Match
|
|
323
404
|
# ============================================================
|
|
324
405
|
|
|
325
|
-
def test_filter_cross_reviewer_match():
|
|
406
|
+
def test_filter_cross_reviewer_match() -> None:
|
|
326
407
|
"""
|
|
327
408
|
Test that findings with different reviewer IDs but same essence are filtered.
|
|
328
409
|
|
|
@@ -381,7 +462,7 @@ def test_filter_cross_reviewer_match():
|
|
|
381
462
|
# Test: _parse_escalation_groups_json()
|
|
382
463
|
# ============================================================
|
|
383
464
|
|
|
384
|
-
def test_parse_escalation_groups_json_valid():
|
|
465
|
+
def test_parse_escalation_groups_json_valid() -> None:
|
|
385
466
|
"""Test parsing valid escalation groups JSON."""
|
|
386
467
|
json_str = '{"escalationGroups": [["GMN-004", "CLD-007"], ["CDX-001", "GMN-005"]]}'
|
|
387
468
|
result = _parse_escalation_groups_json(json_str)
|
|
@@ -391,13 +472,13 @@ def test_parse_escalation_groups_json_valid():
|
|
|
391
472
|
assert ["CDX-001", "GMN-005"] in result
|
|
392
473
|
|
|
393
474
|
|
|
394
|
-
def test_parse_escalation_groups_json_empty():
|
|
475
|
+
def test_parse_escalation_groups_json_empty() -> None:
|
|
395
476
|
"""Test parsing empty escalation groups JSON."""
|
|
396
477
|
result = _parse_escalation_groups_json("")
|
|
397
478
|
assert result == []
|
|
398
479
|
|
|
399
480
|
|
|
400
|
-
def test_parse_escalation_groups_json_malformed():
|
|
481
|
+
def test_parse_escalation_groups_json_malformed() -> None:
|
|
401
482
|
"""Test parsing malformed JSON returns empty list."""
|
|
402
483
|
result = _parse_escalation_groups_json("not valid json {{{")
|
|
403
484
|
assert result == []
|
|
@@ -407,7 +488,7 @@ def test_parse_escalation_groups_json_malformed():
|
|
|
407
488
|
# Test: _parse_escalation_groups_b64()
|
|
408
489
|
# ============================================================
|
|
409
490
|
|
|
410
|
-
def test_parse_escalation_groups_b64_valid():
|
|
491
|
+
def test_parse_escalation_groups_b64_valid() -> None:
|
|
411
492
|
"""Test parsing valid base64-encoded escalation groups."""
|
|
412
493
|
import base64
|
|
413
494
|
json_str = '{"escalationGroups": [["GMN-004", "CLD-007"]]}'
|
|
@@ -419,13 +500,13 @@ def test_parse_escalation_groups_b64_valid():
|
|
|
419
500
|
assert ["GMN-004", "CLD-007"] in result
|
|
420
501
|
|
|
421
502
|
|
|
422
|
-
def test_parse_escalation_groups_b64_empty():
|
|
503
|
+
def test_parse_escalation_groups_b64_empty() -> None:
|
|
423
504
|
"""Test parsing empty base64 string."""
|
|
424
505
|
result = _parse_escalation_groups_b64("")
|
|
425
506
|
assert result == []
|
|
426
507
|
|
|
427
508
|
|
|
428
|
-
def test_parse_escalation_groups_b64_invalid():
|
|
509
|
+
def test_parse_escalation_groups_b64_invalid() -> None:
|
|
429
510
|
"""Test parsing invalid base64 returns empty list."""
|
|
430
511
|
result = _parse_escalation_groups_b64("not-valid-base64!!!")
|
|
431
512
|
assert result == []
|
|
@@ -435,7 +516,7 @@ def test_parse_escalation_groups_b64_invalid():
|
|
|
435
516
|
# Test: Integration - Full Workflow
|
|
436
517
|
# ============================================================
|
|
437
518
|
|
|
438
|
-
def test_integration_full_filter_workflow():
|
|
519
|
+
def test_integration_full_filter_workflow() -> None:
|
|
439
520
|
"""
|
|
440
521
|
Integration test: parse decision log and filter findings.
|
|
441
522
|
|
|
@@ -496,5 +577,125 @@ PR: 456
|
|
|
496
577
|
assert len(result) == 2
|
|
497
578
|
|
|
498
579
|
|
|
580
|
+
# ============================================================
|
|
581
|
+
# Test: _check_existing_comment() - PR comment idempotency
|
|
582
|
+
# ============================================================
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _patch_subprocess_run_for_gh_comments(monkeypatch: pytest.MonkeyPatch, comments: list[dict[str, object]], returncode: int = 0) -> None:
|
|
586
|
+
stdout = json.dumps(comments, ensure_ascii=True)
|
|
587
|
+
|
|
588
|
+
def _fake_run(*_args: object, **_kwargs: object) -> SimpleNamespace:
|
|
589
|
+
return SimpleNamespace(returncode=returncode, stdout=stdout)
|
|
590
|
+
|
|
591
|
+
monkeypatch.setattr(subprocess, "run", _fake_run)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
@pytest.mark.parametrize(
|
|
595
|
+
"comment_type,round_num,expected_header",
|
|
596
|
+
[
|
|
597
|
+
("review-summary", 2, "## Review Summary (Round 2)"),
|
|
598
|
+
("fix-report", 2, "## Fix Report (Round 2)"),
|
|
599
|
+
("final-report", 2, "## Final Report"),
|
|
600
|
+
],
|
|
601
|
+
)
|
|
602
|
+
def test_check_existing_comment_true_when_marker_header_and_runid_match(
|
|
603
|
+
monkeypatch: pytest.MonkeyPatch, comment_type: str, round_num: int, expected_header: str
|
|
604
|
+
) -> None:
|
|
605
|
+
pr_number = 123
|
|
606
|
+
run_id = "run-abc"
|
|
607
|
+
body = "\n".join([_MARKER, "", expected_header, "", f"RunId: {run_id}"])
|
|
608
|
+
_patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}])
|
|
609
|
+
|
|
610
|
+
assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is True
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
@pytest.mark.parametrize(
|
|
614
|
+
"comment_type,round_num,expected_header",
|
|
615
|
+
[
|
|
616
|
+
("review-summary", 3, "## Review Summary (Round 3)"),
|
|
617
|
+
("fix-report", 3, "## Fix Report (Round 3)"),
|
|
618
|
+
("final-report", 3, "## Final Report"),
|
|
619
|
+
],
|
|
620
|
+
)
|
|
621
|
+
def test_check_existing_comment_false_when_marker_missing(
|
|
622
|
+
monkeypatch: pytest.MonkeyPatch, comment_type: str, round_num: int, expected_header: str
|
|
623
|
+
) -> None:
|
|
624
|
+
pr_number = 456
|
|
625
|
+
run_id = "run-xyz"
|
|
626
|
+
body = "\n".join(["", expected_header, "", f"RunId: {run_id}"])
|
|
627
|
+
_patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}])
|
|
628
|
+
|
|
629
|
+
assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is False
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
@pytest.mark.parametrize(
|
|
633
|
+
"comment_type,round_num,expected_header,wrong_header",
|
|
634
|
+
[
|
|
635
|
+
(
|
|
636
|
+
"review-summary",
|
|
637
|
+
2,
|
|
638
|
+
"## Review Summary (Round 2)",
|
|
639
|
+
"## Fix Report (Round 2)",
|
|
640
|
+
),
|
|
641
|
+
(
|
|
642
|
+
"fix-report",
|
|
643
|
+
2,
|
|
644
|
+
"## Fix Report (Round 2)",
|
|
645
|
+
"## Review Summary (Round 2)",
|
|
646
|
+
),
|
|
647
|
+
(
|
|
648
|
+
"final-report",
|
|
649
|
+
2,
|
|
650
|
+
"## Final Report",
|
|
651
|
+
"## Review Summary (Round 2)",
|
|
652
|
+
),
|
|
653
|
+
],
|
|
654
|
+
)
|
|
655
|
+
def test_check_existing_comment_false_when_header_mismatched(
|
|
656
|
+
monkeypatch: pytest.MonkeyPatch, comment_type: str, round_num: int, expected_header: str, wrong_header: str
|
|
657
|
+
) -> None:
|
|
658
|
+
pr_number = 789
|
|
659
|
+
run_id = "run-123"
|
|
660
|
+
|
|
661
|
+
body = "\n".join([_MARKER, "", wrong_header, "", f"RunId: {run_id}"])
|
|
662
|
+
_patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}])
|
|
663
|
+
|
|
664
|
+
assert expected_header not in body
|
|
665
|
+
assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is False
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
@pytest.mark.parametrize(
|
|
669
|
+
"comment_type,round_num,expected_header",
|
|
670
|
+
[
|
|
671
|
+
("review-summary", 1, "## Review Summary (Round 1)"),
|
|
672
|
+
("fix-report", 1, "## Fix Report (Round 1)"),
|
|
673
|
+
("final-report", 1, "## Final Report"),
|
|
674
|
+
],
|
|
675
|
+
)
|
|
676
|
+
def test_check_existing_comment_false_when_runid_mismatched(
|
|
677
|
+
monkeypatch: pytest.MonkeyPatch, comment_type: str, round_num: int, expected_header: str
|
|
678
|
+
) -> None:
|
|
679
|
+
pr_number = 101
|
|
680
|
+
run_id = "run-a"
|
|
681
|
+
other_run_id = "run-b"
|
|
682
|
+
|
|
683
|
+
body = "\n".join([_MARKER, "", expected_header, "", f"RunId: {other_run_id}"])
|
|
684
|
+
_patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}])
|
|
685
|
+
|
|
686
|
+
assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is False
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def test_check_existing_comment_false_when_subprocess_run_nonzero(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
690
|
+
pr_number = 999
|
|
691
|
+
run_id = "run-nonzero"
|
|
692
|
+
round_num = 2
|
|
693
|
+
comment_type = "review-summary"
|
|
694
|
+
body = "\n".join([_MARKER, "", "## Review Summary (Round 2)", "", f"RunId: {run_id}"])
|
|
695
|
+
|
|
696
|
+
_patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}], returncode=1)
|
|
697
|
+
assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is False
|
|
698
|
+
|
|
699
|
+
|
|
499
700
|
if __name__ == "__main__":
|
|
500
|
-
pytest.main([__file__, "-v"])
|
|
701
|
+
_ = pytest.main([__file__, "-v"])
|
|
@@ -15,6 +15,19 @@ agent: sisyphus
|
|
|
15
15
|
## 输入
|
|
16
16
|
|
|
17
17
|
- `{{PR_NUMBER}}`
|
|
18
|
+
- `round`(默认 1,由调用者/循环控制)
|
|
19
|
+
|
|
20
|
+
## 唯一标识 runId(强制)
|
|
21
|
+
|
|
22
|
+
- 全局唯一标识 `runId` 格式:`<PR>-<ROUND>-<HEAD_SHORT>`
|
|
23
|
+
- 其中:
|
|
24
|
+
- `<PR>`:PR 编号
|
|
25
|
+
- `<ROUND>`:当前轮次
|
|
26
|
+
- `<HEAD_SHORT>`:`headOid` 的前 7 位(git rev-parse --short HEAD)
|
|
27
|
+
- 生成者:
|
|
28
|
+
- 第 1 步 `pr-context` 负责计算并返回 `runId`(基于当前 checkout 的 headOid)
|
|
29
|
+
- 后续所有步骤(reviewers, aggregate, fix)必须透传并使用该 `runId`
|
|
30
|
+
- 禁止任何下游步骤自行生成或篡改 `runId`
|
|
18
31
|
|
|
19
32
|
## Cache 约定(强制)
|
|
20
33
|
|
|
@@ -26,6 +39,7 @@ agent: sisyphus
|
|
|
26
39
|
- `pr-precheck`
|
|
27
40
|
- `pr-context`
|
|
28
41
|
- `codex-reviewer`
|
|
42
|
+
|
|
29
43
|
- `claude-reviewer`
|
|
30
44
|
- `gemini-reviewer`
|
|
31
45
|
- `gh-thread-reviewer`
|
|
@@ -37,14 +51,15 @@ agent: sisyphus
|
|
|
37
51
|
|
|
38
52
|
0. Task: `pr-precheck`(强制 gate:编译/预检必须先通过)
|
|
39
53
|
|
|
40
|
-
- prompt 必须包含:`PR #{{PR_NUMBER}}
|
|
41
|
-
- 若返回 `{"error":"..."}
|
|
54
|
+
- prompt 必须包含:`PR #{{PR_NUMBER}}`、`round: <ROUND>`(precheck 需计算并返回 runId,格式同 context)
|
|
55
|
+
- 若返回 `{"error":"..."}`:立即终止本轮并回传错误
|
|
42
56
|
- 若返回 `{"ok":false,"fixFile":"..."}`:
|
|
57
|
+
- 预检阶段 runId 同样基于 `headOid` 生成(`<PR>-<ROUND>-<HEAD_SHORT>`),可直接传给 fix。
|
|
43
58
|
- 最多修复 2 次(防止无限循环):
|
|
44
|
-
- 第 1 次:Task `pr-fix
|
|
59
|
+
- 第 1 次:Task `pr-fix`(传入 `fixFile`, `runId`, `round`)→ 再 Task `pr-precheck`
|
|
45
60
|
- 若仍返回 `{"ok":false,"fixFile":"..."}`:第 2 次 Task `pr-fix` → 再 Task `pr-precheck`
|
|
46
61
|
- 若仍不是 `{"ok":true}`:终止并回传错误(建议:`{"error":"PRECHECK_NOT_CLEAN_AFTER_FIX"}`)
|
|
47
|
-
|
|
62
|
+
- 注意:预检失败产生的修复也应记录在 Decision Log 中(essence: `__precheck__` 或具体错误信息,file: `__precheck__`),以便后续追踪。
|
|
48
63
|
|
|
49
64
|
## 循环(最多 3 轮)
|
|
50
65
|
|
|
@@ -61,7 +76,8 @@ agent: sisyphus
|
|
|
61
76
|
|
|
62
77
|
- prompt 必须包含:`PR #{{PR_NUMBER}}`、`round: <ROUND>`
|
|
63
78
|
- 若返回 `{"error":"..."}`:立即终止本轮并回传错误(不再调用 reviewers)
|
|
64
|
-
- 取出:`contextFile`、`runId`、`headOid
|
|
79
|
+
- 取出:`contextFile`、`runId`、`headOid`
|
|
80
|
+
- **runId 校验**:确认返回的 `runId` 符合 `<PR>-<ROUND>-<HEAD_SHORT>` 格式
|
|
65
81
|
- **CRITICAL**: 必须等待此 Task 成功完成并获取到 `contextFile` 后,才能进入 Step 2
|
|
66
82
|
|
|
67
83
|
**检查 Decision Log**:
|
|
@@ -77,14 +93,14 @@ agent: sisyphus
|
|
|
77
93
|
- `round: <ROUND>`
|
|
78
94
|
- `runId: <RUN_ID>`(来自 Step 1 的输出,必须透传,禁止自行生成)
|
|
79
95
|
- `contextFile: ./.cache/<file>.md`(来自 Step 1 的输出)
|
|
80
|
-
- `decisionLogFile: ./.cache/decision-log-pr{{PR_NUMBER}}.md
|
|
96
|
+
- `decisionLogFile: ./.cache/decision-log-pr{{PR_NUMBER}}.md`(如存在)
|
|
81
97
|
- reviewer 默认读 `contextFile`;如果 `decisionLogFile` 存在,reviewer 应在 prompt 中提供该文件路径以参考前轮决策;必要时允许用 `git/gh` 只读命令拿 diff
|
|
82
98
|
- 忽略问题:1.格式化代码引起的噪音 2.已经lint检查以外的格式问题 3.忽略单元测试不足的问题
|
|
83
99
|
- 特别关注: 逻辑、安全、性能、可维护性
|
|
84
|
-
-
|
|
85
|
-
-
|
|
86
|
-
|
|
87
|
-
|
|
100
|
+
- 遵守 Decision Log:
|
|
101
|
+
- 已修复(Fixed):不再提
|
|
102
|
+
- 已拒绝(Rejected):除非优先级升级(P_new - P_old >= 2),否则不再提
|
|
103
|
+
- 任何新发现必须基于当前 `runId` 对应的代码状态
|
|
88
104
|
- 每个 reviewer 输出:`reviewFile: ./.cache/<file>.md`(Markdown)
|
|
89
105
|
|
|
90
106
|
3. Task: `pr-review-aggregate`
|
|
@@ -93,6 +109,10 @@ agent: sisyphus
|
|
|
93
109
|
- 输出:`{"stop":true}` 或 `{"stop":false,"fixFile":"..."}`
|
|
94
110
|
- 若 `stop=true`:本轮结束并退出循环
|
|
95
111
|
- **唯一性约束**: 每轮只能发布一次 Review Summary;脚本内置幂等检查,重复调用不会重复发布
|
|
112
|
+
- 智能聚合:
|
|
113
|
+
- 使用 LLM 对比 decision-log 中的 `essence` 与新 finding
|
|
114
|
+
- 仅当问题本质相同且优先级 delta < 2 时,自动归为 Repeated/Ignored
|
|
115
|
+
- 否则视为 New Issue 或 Escalation
|
|
96
116
|
|
|
97
117
|
4. Task: `pr-fix`
|
|
98
118
|
|
|
@@ -102,9 +122,13 @@ agent: sisyphus
|
|
|
102
122
|
- `runId: <RUN_ID>`(来自 Step 1 的输出,必须透传,禁止自行生成)
|
|
103
123
|
- `fixFile: ./.cache/<file>.md`
|
|
104
124
|
- 约定:`pr-fix` 对每个 findingId 单独 commit + push(一个 findingId 一个 commit),结束后再 `git push` 兜底
|
|
105
|
-
|
|
125
|
+
- 决策记录:
|
|
126
|
+
- 修复成功:追加 Fixed 记录(含 `essence`)到 Decision Log
|
|
127
|
+
- 拒绝/无法修复:追加 Rejected 记录(含 `reason`, `essence`)到 Decision Log
|
|
128
|
+
- 范围限制:essence 匹配必须在 **同一个文件** 内(不支持跨文件/重命名追踪)
|
|
106
129
|
- pr-fix 输出:`fixReportFile: ./.cache/<file>.md`(Markdown)
|
|
107
130
|
|
|
131
|
+
|
|
108
132
|
5. Task: `pr-review-aggregate`(发布修复评论)
|
|
109
133
|
|
|
110
134
|
- prompt 必须包含:`PR #{{PR_NUMBER}}`、`round: <ROUND>`、`runId: <RUN_ID>`、`fixReportFile: ./.cache/<file>.md`
|
|
@@ -119,6 +143,32 @@ agent: sisyphus
|
|
|
119
143
|
|
|
120
144
|
- 回到 1(进入下一轮 reviewers)
|
|
121
145
|
|
|
146
|
+
## 本地验证(脚本直跑)
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
# 0) 先确保 gh 已认证(host 从 git remote origin 推断;必要时用 --hostname)
|
|
150
|
+
gh auth status
|
|
151
|
+
|
|
152
|
+
# 1) precheck(round 1)
|
|
153
|
+
python3 "./@opencode/agents/pr_precheck.py" --pr <PR_NUMBER> --round 1
|
|
154
|
+
|
|
155
|
+
# 2) context(round 1)
|
|
156
|
+
python3 "./@opencode/agents/pr_context.py" --pr <PR_NUMBER> --round 1
|
|
157
|
+
|
|
158
|
+
# 3) 校验:两者都必须输出单行 JSON,且 runId 必须一致
|
|
159
|
+
python3 "./@opencode/agents/pr_precheck.py" --pr <PR_NUMBER> --round 1 > ./.cache/_precheck.json
|
|
160
|
+
python3 "./@opencode/agents/pr_context.py" --pr <PR_NUMBER> --round 1 > ./.cache/_context.json
|
|
161
|
+
python3 - <<'PY'
|
|
162
|
+
import json
|
|
163
|
+
p=json.load(open('./.cache/_precheck.json'))
|
|
164
|
+
c=json.load(open('./.cache/_context.json'))
|
|
165
|
+
assert p.get('runId') == c.get('runId'), (p.get('runId'), c.get('runId'))
|
|
166
|
+
print('OK', p.get('runId'))
|
|
167
|
+
PY
|
|
168
|
+
|
|
169
|
+
# 4) 运行脚本相关测试(注意:pytest 把 @ 当作 argfile;必须加 ./ 并加引号)
|
|
170
|
+
python3 -m pytest -q "./@opencode/agents/test_pr_review_aggregate.py"
|
|
171
|
+
```
|
|
122
172
|
|
|
123
173
|
## 终止与收尾(强制)
|
|
124
174
|
|
package/lib/cli/args.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
export function getCleanArgs(args = []) {
|
|
2
2
|
const result = []
|
|
3
|
-
let afterDoubleDash = false
|
|
4
3
|
for (const arg of args) {
|
|
5
4
|
if (arg === '--') {
|
|
6
|
-
afterDoubleDash = true
|
|
7
5
|
break
|
|
8
6
|
}
|
|
9
7
|
if (arg.startsWith('-')) continue
|
|
@@ -12,6 +10,27 @@ export function getCleanArgs(args = []) {
|
|
|
12
10
|
return result
|
|
13
11
|
}
|
|
14
12
|
|
|
13
|
+
// Like getCleanArgs(), but also strips values consumed by flags that expect a value.
|
|
14
|
+
// consumedFlagValueIndexes: Set<number> of indexes in the original argv that should be skipped.
|
|
15
|
+
export function getCleanArgsWithConsumedValues(args = [], consumedFlagValueIndexes = new Set()) {
|
|
16
|
+
const result = []
|
|
17
|
+
const consumed = consumedFlagValueIndexes instanceof Set
|
|
18
|
+
? consumedFlagValueIndexes
|
|
19
|
+
: new Set(consumedFlagValueIndexes || [])
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < args.length; i++) {
|
|
22
|
+
const arg = args[i]
|
|
23
|
+
if (arg === '--') {
|
|
24
|
+
break
|
|
25
|
+
}
|
|
26
|
+
if (consumed.has(i)) continue
|
|
27
|
+
if (arg.startsWith('-')) continue
|
|
28
|
+
result.push(arg)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return result
|
|
32
|
+
}
|
|
33
|
+
|
|
15
34
|
export function getPassthroughArgs(args = []) {
|
|
16
35
|
const doubleDashIndex = args.indexOf('--')
|
|
17
36
|
if (doubleDashIndex === -1) return []
|
|
@@ -2,6 +2,28 @@ import { logger } from '../../logger.js'
|
|
|
2
2
|
import { envManager } from '../../env.js'
|
|
3
3
|
import { validateEnvironment } from '../../validate-env.js'
|
|
4
4
|
|
|
5
|
+
export function parseTelegramWebhookFlags(argv = []) {
|
|
6
|
+
const args = Array.isArray(argv) ? argv : []
|
|
7
|
+
|
|
8
|
+
const idx = args.indexOf('--webhook-path')
|
|
9
|
+
const webhookPath = idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined
|
|
10
|
+
|
|
11
|
+
const dryRun = args.includes('--webhook-dry-run') ? true : undefined
|
|
12
|
+
|
|
13
|
+
// 默认值由下游根据 environment 决定,这里只负责覆盖
|
|
14
|
+
const strict = args.includes('--strict-webhook')
|
|
15
|
+
? true
|
|
16
|
+
: args.includes('--no-strict-webhook')
|
|
17
|
+
? false
|
|
18
|
+
: undefined
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
webhookPath,
|
|
22
|
+
dryRun,
|
|
23
|
+
strict,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
5
27
|
export async function handleDeploy(cli, args) {
|
|
6
28
|
const target = args[0]
|
|
7
29
|
if (!target) {
|
|
@@ -59,7 +81,9 @@ export async function handleDeploy(cli, args) {
|
|
|
59
81
|
// 加载环境变量层,但不校验后端必需变量
|
|
60
82
|
const layeredEnv = envManager.collectEnvFromLayers(null, environment)
|
|
61
83
|
if (envManager.latestEnvWarnings && envManager.latestEnvWarnings.length > 0) {
|
|
62
|
-
envManager.latestEnvWarnings.forEach(message =>
|
|
84
|
+
envManager.latestEnvWarnings.forEach(message => {
|
|
85
|
+
logger.warn(message)
|
|
86
|
+
})
|
|
63
87
|
}
|
|
64
88
|
|
|
65
89
|
// 仅在目标变量不存在或是占位符时才使用 .env 文件的值
|
|
@@ -72,5 +96,10 @@ export async function handleDeploy(cli, args) {
|
|
|
72
96
|
envManager.syncEnvironments(environment)
|
|
73
97
|
|
|
74
98
|
const { deployToVercel } = await import('../../vercel-deploy.js')
|
|
75
|
-
|
|
99
|
+
|
|
100
|
+
const telegramWebhook = normalizedTarget === 'telegram-bot'
|
|
101
|
+
? parseTelegramWebhookFlags(cli.args)
|
|
102
|
+
: null
|
|
103
|
+
|
|
104
|
+
await deployToVercel(normalizedTarget, { environment, telegramWebhook })
|
|
76
105
|
}
|