@ranger1/dx 0.1.69 → 0.1.71

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,751 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Unit tests for pr_review_aggregate.py decision log parsing and filtering.
4
+
5
+ Tests cover:
6
+ 1. _parse_decision_log() - parsing markdown decision logs
7
+ 2. _filter_by_decision_log() - filtering findings based on prior decisions
8
+ 3. Edge cases: empty input, malformed data, cross-reviewer matching
9
+ """
10
+
11
+ import importlib.util
12
+ import json
13
+ import subprocess
14
+ from collections.abc import Mapping, Sequence
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
30
+
31
+
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
+ _parse_review_findings = cast(
48
+ Callable[[str], list[dict[str, object]]],
49
+ getattr(_pr_review_aggregate, "_parse_review_findings"),
50
+ )
51
+ _check_existing_comment = cast(
52
+ Callable[[int, str, int, str], bool],
53
+ getattr(_pr_review_aggregate, "_check_existing_comment"),
54
+ )
55
+ _MARKER = cast(str, getattr(_pr_review_aggregate, "MARKER"))
56
+
57
+
58
+ # ============================================================
59
+ # Fixtures
60
+ # ============================================================
61
+
62
+ @pytest.fixture
63
+ def empty_decision_log() -> str:
64
+ """Empty decision log markdown."""
65
+ return ""
66
+
67
+
68
+ @pytest.fixture
69
+ def valid_decision_log() -> str:
70
+ """Valid decision log with Fixed and Rejected entries."""
71
+ return """# Decision Log
72
+
73
+ PR: 123
74
+
75
+ ## Round 1
76
+
77
+ ### Fixed
78
+ - id: SEC-001
79
+ file: apps/backend/src/api.ts
80
+ commit: abc123
81
+ essence: JSON.parse 未捕获异常
82
+
83
+ - id: STY-002
84
+ file: apps/front/src/ErrorBoundary.tsx
85
+ commit: def456
86
+ essence: 缺少错误边界处理
87
+
88
+ ### Rejected
89
+ - id: STY-004
90
+ file: apps/front/src/Component.tsx
91
+ priority: P2
92
+ reason: 需要产品决策,超出 PR 范围
93
+ essence: 组件拆分建议
94
+
95
+ - id: LOG-003
96
+ file: apps/backend/src/db.ts
97
+ priority: P3
98
+ reason: 性能优化非当前优先级
99
+ essence: 批量查询优化
100
+ """
101
+
102
+
103
+ @pytest.fixture
104
+ def valid_decision_log_legacy_no_file() -> str:
105
+ """Legacy decision log fixture without the file: field (backward compat)."""
106
+ return """# Decision Log
107
+
108
+ PR: 123
109
+
110
+ ## Round 1
111
+
112
+ ### Fixed
113
+ - id: SEC-001
114
+ commit: abc123
115
+ essence: JSON.parse 未捕获异常
116
+
117
+ - id: STY-002
118
+ commit: def456
119
+ essence: 缺少错误边界处理
120
+
121
+ ### Rejected
122
+ - id: STY-004
123
+ priority: P2
124
+ reason: 需要产品决策,超出 PR 范围
125
+ essence: 组件拆分建议
126
+
127
+ - id: LOG-003
128
+ priority: P3
129
+ reason: 性能优化非当前优先级
130
+ essence: 批量查询优化
131
+ """
132
+
133
+
134
+ @pytest.fixture
135
+ def malformed_decision_log() -> str:
136
+ """Malformed decision log with missing fields and bad formatting."""
137
+ return """# Decision Log
138
+
139
+ PR: 123
140
+
141
+ ### Fixed
142
+ - id: BROKEN-001
143
+ # Missing essence field
144
+
145
+ ### Rejected
146
+ - id: BROKEN-002
147
+ priority: P2
148
+ # Missing essence and reason
149
+
150
+ Some random text that should be ignored
151
+
152
+ - id: BROKEN-003
153
+ this is not a valid field format
154
+ """
155
+
156
+
157
+ @pytest.fixture
158
+ def sample_findings() -> list[dict[str, object]]:
159
+ """Sample findings list for filter tests."""
160
+ return [
161
+ {
162
+ "id": "SEC-001",
163
+ "priority": "P1",
164
+ "category": "bug",
165
+ "file": "api.ts",
166
+ "line": "42",
167
+ "title": "JSON parse error",
168
+ "description": "JSON.parse 未捕获异常",
169
+ "suggestion": "Add try-catch"
170
+ },
171
+ {
172
+ "id": "STY-004",
173
+ "priority": "P2",
174
+ "category": "quality",
175
+ "file": "Component.tsx",
176
+ "line": "100",
177
+ "title": "Component split",
178
+ "description": "组件拆分建议",
179
+ "suggestion": "Split into smaller components"
180
+ },
181
+ {
182
+ "id": "LOG-007",
183
+ "priority": "P0",
184
+ "category": "bug",
185
+ "file": "Component.tsx",
186
+ "line": "100",
187
+ "title": "Component split (escalated)",
188
+ "description": "组件拆分建议 - 升级为 P0",
189
+ "suggestion": "Split into smaller components - critical"
190
+ },
191
+ {
192
+ "id": "NEW-001",
193
+ "priority": "P1",
194
+ "category": "bug",
195
+ "file": "utils.ts",
196
+ "line": "20",
197
+ "title": "New issue",
198
+ "description": "This is a new issue",
199
+ "suggestion": "Fix it"
200
+ }
201
+ ]
202
+
203
+
204
+ @pytest.fixture
205
+ def prior_decisions() -> list[dict[str, object]]:
206
+ """Sample prior decisions from _parse_decision_log."""
207
+ return [
208
+ {
209
+ "id": "SEC-001",
210
+ "status": "fixed",
211
+ "commit": "abc123",
212
+ "essence": "JSON.parse 未捕获异常"
213
+ },
214
+ {
215
+ "id": "STY-004",
216
+ "status": "rejected",
217
+ "priority": "P2",
218
+ "reason": "需要产品决策,超出 PR 范围",
219
+ "essence": "组件拆分建议"
220
+ }
221
+ ]
222
+
223
+
224
+ # ============================================================
225
+ # Test: _parse_decision_log() - Empty Input
226
+ # ============================================================
227
+
228
+ def test_parse_decision_log_empty(empty_decision_log: str) -> None:
229
+ """
230
+ Test that empty decision log returns empty list.
231
+
232
+ Given: empty string
233
+ When: _parse_decision_log() is called
234
+ Then: returns []
235
+ """
236
+ result = _parse_decision_log(empty_decision_log)
237
+ assert result == []
238
+ assert isinstance(result, list)
239
+
240
+
241
+ # ============================================================
242
+ # Test: _parse_decision_log() - Valid Input
243
+ # ============================================================
244
+
245
+ def test_parse_decision_log_valid(valid_decision_log: str) -> None:
246
+ """
247
+ Test that valid decision log is parsed into structured data.
248
+
249
+ Given: valid markdown with Fixed and Rejected sections
250
+ When: _parse_decision_log() is called
251
+ Then: returns list of dicts with id, status, essence, and optional fields
252
+ """
253
+ result = _parse_decision_log(valid_decision_log)
254
+
255
+ # Should have 4 entries (2 Fixed, 2 Rejected)
256
+ assert len(result) == 4
257
+
258
+ # Verify first Fixed entry
259
+ fixed_1 = result[0]
260
+ assert fixed_1["id"] == "SEC-001"
261
+ assert fixed_1["status"] == "fixed"
262
+ assert fixed_1["file"] == "apps/backend/src/api.ts"
263
+ assert fixed_1["commit"] == "abc123"
264
+ assert fixed_1["essence"] == "JSON.parse 未捕获异常"
265
+
266
+ # Verify second Fixed entry
267
+ fixed_2 = result[1]
268
+ assert fixed_2["id"] == "STY-002"
269
+ assert fixed_2["status"] == "fixed"
270
+ assert fixed_2["file"] == "apps/front/src/ErrorBoundary.tsx"
271
+ assert fixed_2["commit"] == "def456"
272
+ assert fixed_2["essence"] == "缺少错误边界处理"
273
+
274
+ # Verify first Rejected entry
275
+ rejected_1 = result[2]
276
+ assert rejected_1["id"] == "STY-004"
277
+ assert rejected_1["status"] == "rejected"
278
+ assert rejected_1["file"] == "apps/front/src/Component.tsx"
279
+ assert rejected_1["priority"] == "P2"
280
+ assert rejected_1["reason"] == "需要产品决策,超出 PR 范围"
281
+ assert rejected_1["essence"] == "组件拆分建议"
282
+
283
+ # Verify second Rejected entry
284
+ rejected_2 = result[3]
285
+ assert rejected_2["id"] == "LOG-003"
286
+ assert rejected_2["status"] == "rejected"
287
+ assert rejected_2["file"] == "apps/backend/src/db.ts"
288
+ assert rejected_2["priority"] == "P3"
289
+
290
+
291
+ def test_parse_decision_log_legacy_without_file(valid_decision_log_legacy_no_file: str) -> None:
292
+ """Decision log entries without file: should still parse (backward compat)."""
293
+ result = _parse_decision_log(valid_decision_log_legacy_no_file)
294
+
295
+ # Should have 4 entries (2 Fixed, 2 Rejected)
296
+ assert len(result) == 4
297
+
298
+ # Basic shape should still be present
299
+ for entry in result:
300
+ assert "id" in entry
301
+ assert "status" in entry
302
+
303
+ # And file should be optional
304
+ assert all(("file" not in e) or (e["file"] in (None, "")) for e in result)
305
+
306
+
307
+ # ============================================================
308
+ # Test: _parse_decision_log() - Malformed Input
309
+ # ============================================================
310
+
311
+ def test_parse_decision_log_malformed(malformed_decision_log: str) -> None:
312
+ """
313
+ Test that malformed decision log degrades gracefully.
314
+
315
+ Given: decision log with missing required fields
316
+ When: _parse_decision_log() is called
317
+ Then: returns partial data without raising exceptions
318
+ """
319
+ # Should not raise exception
320
+ result = _parse_decision_log(malformed_decision_log)
321
+
322
+ # Should return some data (even if incomplete)
323
+ assert isinstance(result, list)
324
+
325
+ # Entries should have at least id and status
326
+ for entry in result:
327
+ assert "id" in entry
328
+ assert "status" in entry
329
+
330
+
331
+ # ============================================================
332
+ # Test: _filter_by_decision_log() - Fixed Issues
333
+ # ============================================================
334
+
335
+ def test_filter_fixed_issues(sample_findings: list[dict[str, object]], prior_decisions: list[dict[str, object]]) -> None:
336
+ """
337
+ Test that findings matching Fixed decisions are filtered out.
338
+
339
+ Given: findings containing SEC-001 which is in Fixed decisions
340
+ When: _filter_by_decision_log() is called with empty escalation_groups
341
+ Then: SEC-001 is filtered out
342
+ """
343
+ escalation_groups: list[list[str]] = []
344
+
345
+ result = _filter_by_decision_log(sample_findings, prior_decisions, escalation_groups)
346
+
347
+ # SEC-001 should be filtered (it's in Fixed decisions)
348
+ result_ids = [f["id"] for f in result]
349
+ assert "SEC-001" not in result_ids
350
+
351
+ # Other findings should remain
352
+ assert "STY-004" in result_ids or "LOG-007" in result_ids or "NEW-001" in result_ids
353
+
354
+
355
+ # ============================================================
356
+ # Test: _filter_by_decision_log() - Rejected Without Escalation
357
+ # ============================================================
358
+
359
+ def test_filter_rejected_without_escalation(sample_findings: list[dict[str, object]], prior_decisions: list[dict[str, object]]) -> None:
360
+ """
361
+ Test that findings matching Rejected decisions are filtered out when NOT in escalation_groups.
362
+
363
+ Given: findings containing STY-004 which is in Rejected decisions
364
+ and escalation_groups is empty
365
+ When: _filter_by_decision_log() is called
366
+ Then: STY-004 is filtered out
367
+ """
368
+ escalation_groups: list[list[str]] = []
369
+
370
+ result = _filter_by_decision_log(sample_findings, prior_decisions, escalation_groups)
371
+
372
+ # STY-004 should be filtered (it's Rejected and not escalated)
373
+ result_ids = [f["id"] for f in result]
374
+ assert "STY-004" not in result_ids
375
+
376
+ # New findings should remain
377
+ assert "NEW-001" in result_ids
378
+
379
+
380
+ # ============================================================
381
+ # Test: _filter_by_decision_log() - Rejected With Escalation
382
+ # ============================================================
383
+
384
+ def test_filter_rejected_with_escalation(sample_findings: list[dict[str, object]], prior_decisions: list[dict[str, object]]) -> None:
385
+ """
386
+ Test that findings matching Rejected decisions are kept when in escalation_groups.
387
+
388
+ Given: findings containing LOG-007 which is an escalation of STY-004
389
+ and escalation_groups contains ["STY-004", "LOG-007"]
390
+ When: _filter_by_decision_log() is called
391
+ Then: LOG-007 is NOT filtered (it's an escalation)
392
+ """
393
+ # STY-004 (Rejected P2) -> LOG-007 (escalated to P0, ≥2 level jump)
394
+ escalation_groups = [["STY-004", "LOG-007"]]
395
+
396
+ result = _filter_by_decision_log(sample_findings, prior_decisions, escalation_groups)
397
+
398
+ # LOG-007 should NOT be filtered (it's an escalation)
399
+ result_ids = [f["id"] for f in result]
400
+ assert "LOG-007" in result_ids
401
+
402
+ # STY-004 itself (P2) should still be filtered
403
+ assert "STY-004" not in result_ids
404
+
405
+
406
+ # ============================================================
407
+ # Test: _filter_by_decision_log() - Cross-Reviewer Match
408
+ # ============================================================
409
+
410
+ def test_filter_cross_reviewer_match() -> None:
411
+ """
412
+ Test that findings with different reviewer IDs but same essence are filtered.
413
+
414
+ Given: findings containing STY-005 (different ID from SEC-001)
415
+ but prior decisions contain SEC-001 as Fixed
416
+ and escalation_groups links them: ["SEC-001", "STY-005"]
417
+ When: _filter_by_decision_log() is called
418
+ Then: STY-005 is filtered (matched via escalation group to Fixed decision)
419
+ """
420
+ findings = [
421
+ {
422
+ "id": "STY-005",
423
+ "priority": "P1",
424
+ "category": "bug",
425
+ "file": "api.ts",
426
+ "line": "42",
427
+ "title": "JSON parse error",
428
+ "description": "JSON.parse 未捕获异常 (same essence as SEC-001)",
429
+ "suggestion": "Add try-catch"
430
+ },
431
+ {
432
+ "id": "NEW-002",
433
+ "priority": "P2",
434
+ "category": "quality",
435
+ "file": "utils.ts",
436
+ "line": "10",
437
+ "title": "Different issue",
438
+ "description": "Completely different",
439
+ "suggestion": "Fix differently"
440
+ }
441
+ ]
442
+
443
+ prior_decisions = [
444
+ {
445
+ "id": "SEC-001",
446
+ "status": "fixed",
447
+ "commit": "abc123",
448
+ "essence": "JSON.parse 未捕获异常"
449
+ }
450
+ ]
451
+
452
+ # Escalation group indicates STY-005 is related to SEC-001
453
+ escalation_groups = [["SEC-001", "STY-005"]]
454
+
455
+ result = _filter_by_decision_log(findings, prior_decisions, escalation_groups)
456
+
457
+ # STY-005 should be filtered (linked to Fixed SEC-001 via escalation group)
458
+ result_ids = [f["id"] for f in result]
459
+ assert "STY-005" not in result_ids
460
+
461
+ # NEW-002 should remain
462
+ assert "NEW-002" in result_ids
463
+
464
+
465
+ # ============================================================
466
+ # Test: _parse_escalation_groups_json()
467
+ # ============================================================
468
+
469
+ def test_parse_escalation_groups_json_valid() -> None:
470
+ """Test parsing valid escalation groups JSON."""
471
+ json_str = '{"escalationGroups": [["STY-004", "LOG-007"], ["SEC-001", "STY-005"]]}'
472
+ result = _parse_escalation_groups_json(json_str)
473
+
474
+ assert len(result) == 2
475
+ assert ["STY-004", "LOG-007"] in result
476
+ assert ["SEC-001", "STY-005"] in result
477
+
478
+
479
+ def test_parse_escalation_groups_json_empty() -> None:
480
+ """Test parsing empty escalation groups JSON."""
481
+ result = _parse_escalation_groups_json("")
482
+ assert result == []
483
+
484
+
485
+ def test_parse_escalation_groups_json_malformed() -> None:
486
+ """Test parsing malformed JSON returns empty list."""
487
+ result = _parse_escalation_groups_json("not valid json {{{")
488
+ assert result == []
489
+
490
+
491
+ # ============================================================
492
+ # Test: _parse_escalation_groups_b64()
493
+ # ============================================================
494
+
495
+ def test_parse_escalation_groups_b64_valid() -> None:
496
+ """Test parsing valid base64-encoded escalation groups."""
497
+ import base64
498
+ json_str = '{"escalationGroups": [["STY-004", "LOG-007"]]}'
499
+ b64_str = base64.b64encode(json_str.encode("utf-8")).decode("ascii")
500
+
501
+ result = _parse_escalation_groups_b64(b64_str)
502
+
503
+ assert len(result) == 1
504
+ assert ["STY-004", "LOG-007"] in result
505
+
506
+
507
+ def test_parse_escalation_groups_b64_empty() -> None:
508
+ """Test parsing empty base64 string."""
509
+ result = _parse_escalation_groups_b64("")
510
+ assert result == []
511
+
512
+
513
+ def test_parse_escalation_groups_b64_invalid() -> None:
514
+ """Test parsing invalid base64 returns empty list."""
515
+ result = _parse_escalation_groups_b64("not-valid-base64!!!")
516
+ assert result == []
517
+
518
+
519
+ # ============================================================
520
+ # Test: _parse_review_findings()
521
+ # ============================================================
522
+
523
+ def test_parse_review_findings_supports_current_reviewer_format() -> None:
524
+ """Current reviewer output uses plain key-value finding blocks."""
525
+ review_md = """# Review (SEC)
526
+ PR: 2934
527
+ Round: 1
528
+
529
+ ## Findings
530
+ ### SEC-001
531
+ id: SEC-001
532
+ priority: P1
533
+ category: Path Traversal
534
+ file: scripts/release/backend-deploy-release.sh
535
+ line: 128
536
+ title: 发布目录名未校验导致路径穿越与高危删除
537
+ description: 说明文本
538
+ suggestion: 修复建议
539
+ """
540
+ result = _parse_review_findings(review_md)
541
+ assert len(result) == 1
542
+ assert result[0]["id"] == "SEC-001"
543
+ assert result[0]["priority"] == "P1"
544
+ assert result[0]["file"] == "scripts/release/backend-deploy-release.sh"
545
+
546
+
547
+ def test_parse_review_findings_keeps_legacy_list_format() -> None:
548
+ """Legacy list-style findings should remain parseable."""
549
+ review_md = """## Findings
550
+ - id: LOG-001
551
+ priority: P1
552
+ category: logic
553
+ file: apps/api/src/service.ts
554
+ line: 10
555
+ title: 标题
556
+ description: 描述
557
+ suggestion: 建议
558
+ """
559
+ result = _parse_review_findings(review_md)
560
+ assert len(result) == 1
561
+ assert result[0]["id"] == "LOG-001"
562
+ assert result[0]["priority"] == "P1"
563
+
564
+
565
+ # ============================================================
566
+ # Test: Integration - Full Workflow
567
+ # ============================================================
568
+
569
+ def test_integration_full_filter_workflow() -> None:
570
+ """
571
+ Integration test: parse decision log and filter findings.
572
+
573
+ Simulates real workflow:
574
+ 1. Parse decision log markdown
575
+ 2. Parse escalation groups
576
+ 3. Filter findings based on decisions and escalations
577
+ """
578
+ decision_log_md = """# Decision Log
579
+
580
+ PR: 456
581
+
582
+ ## Round 1
583
+
584
+ ### Fixed
585
+ - id: SEC-010
586
+ commit: sha1
587
+ essence: 类型错误修复
588
+
589
+ ### Rejected
590
+ - id: STY-020
591
+ priority: P3
592
+ reason: 低优先级优化
593
+ essence: 性能优化建议
594
+ """
595
+
596
+ findings = [
597
+ {"id": "SEC-010", "priority": "P1", "category": "bug", "file": "a.ts", "line": "1", "title": "Type error", "description": "类型错误修复", "suggestion": "Fix"},
598
+ {"id": "STY-020", "priority": "P3", "category": "perf", "file": "b.ts", "line": "2", "title": "Perf opt", "description": "性能优化建议", "suggestion": "Optimize"},
599
+ {"id": "LOG-030", "priority": "P1", "category": "perf", "file": "b.ts", "line": "2", "title": "Perf opt escalated", "description": "性能优化建议 - 升级", "suggestion": "Optimize now"},
600
+ {"id": "NEW-100", "priority": "P2", "category": "quality", "file": "c.ts", "line": "3", "title": "New", "description": "新问题", "suggestion": "Fix new"},
601
+ ]
602
+
603
+ # Parse decision log
604
+ prior_decisions = _parse_decision_log(decision_log_md)
605
+ assert len(prior_decisions) == 2
606
+
607
+ # Escalation: STY-020 (P3) -> LOG-030 (P1, ≥2 level jump)
608
+ escalation_groups = [["STY-020", "LOG-030"]]
609
+
610
+ # Filter findings
611
+ result = _filter_by_decision_log(findings, prior_decisions, escalation_groups)
612
+ result_ids = [f["id"] for f in result]
613
+
614
+ # SEC-010 should be filtered (Fixed)
615
+ assert "SEC-010" not in result_ids
616
+
617
+ # STY-020 should be filtered (Rejected, not escalated)
618
+ assert "STY-020" not in result_ids
619
+
620
+ # LOG-030 should remain (escalation of Rejected)
621
+ assert "LOG-030" in result_ids
622
+
623
+ # NEW-100 should remain (new issue)
624
+ assert "NEW-100" in result_ids
625
+
626
+ # Final count: 2 findings remain
627
+ assert len(result) == 2
628
+
629
+
630
+ # ============================================================
631
+ # Test: _check_existing_comment() - PR comment idempotency
632
+ # ============================================================
633
+
634
+
635
+ def _patch_subprocess_run_for_gh_comments(monkeypatch: pytest.MonkeyPatch, comments: list[dict[str, object]], returncode: int = 0) -> None:
636
+ stdout = json.dumps(comments, ensure_ascii=True)
637
+
638
+ def _fake_run(*_args: object, **_kwargs: object) -> SimpleNamespace:
639
+ return SimpleNamespace(returncode=returncode, stdout=stdout)
640
+
641
+ monkeypatch.setattr(subprocess, "run", _fake_run)
642
+
643
+
644
+ @pytest.mark.parametrize(
645
+ "comment_type,round_num,expected_header",
646
+ [
647
+ ("review-summary", 2, "## Review Summary (Round 2)"),
648
+ ("fix-report", 2, "## Fix Report (Round 2)"),
649
+ ("final-report", 2, "## Final Report"),
650
+ ],
651
+ )
652
+ def test_check_existing_comment_true_when_marker_header_and_runid_match(
653
+ monkeypatch: pytest.MonkeyPatch, comment_type: str, round_num: int, expected_header: str
654
+ ) -> None:
655
+ pr_number = 123
656
+ run_id = "run-abc"
657
+ body = "\n".join([_MARKER, "", expected_header, "", f"RunId: {run_id}"])
658
+ _patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}])
659
+
660
+ assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is True
661
+
662
+
663
+ @pytest.mark.parametrize(
664
+ "comment_type,round_num,expected_header",
665
+ [
666
+ ("review-summary", 3, "## Review Summary (Round 3)"),
667
+ ("fix-report", 3, "## Fix Report (Round 3)"),
668
+ ("final-report", 3, "## Final Report"),
669
+ ],
670
+ )
671
+ def test_check_existing_comment_false_when_marker_missing(
672
+ monkeypatch: pytest.MonkeyPatch, comment_type: str, round_num: int, expected_header: str
673
+ ) -> None:
674
+ pr_number = 456
675
+ run_id = "run-xyz"
676
+ body = "\n".join(["", expected_header, "", f"RunId: {run_id}"])
677
+ _patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}])
678
+
679
+ assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is False
680
+
681
+
682
+ @pytest.mark.parametrize(
683
+ "comment_type,round_num,expected_header,wrong_header",
684
+ [
685
+ (
686
+ "review-summary",
687
+ 2,
688
+ "## Review Summary (Round 2)",
689
+ "## Fix Report (Round 2)",
690
+ ),
691
+ (
692
+ "fix-report",
693
+ 2,
694
+ "## Fix Report (Round 2)",
695
+ "## Review Summary (Round 2)",
696
+ ),
697
+ (
698
+ "final-report",
699
+ 2,
700
+ "## Final Report",
701
+ "## Review Summary (Round 2)",
702
+ ),
703
+ ],
704
+ )
705
+ def test_check_existing_comment_false_when_header_mismatched(
706
+ monkeypatch: pytest.MonkeyPatch, comment_type: str, round_num: int, expected_header: str, wrong_header: str
707
+ ) -> None:
708
+ pr_number = 789
709
+ run_id = "run-123"
710
+
711
+ body = "\n".join([_MARKER, "", wrong_header, "", f"RunId: {run_id}"])
712
+ _patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}])
713
+
714
+ assert expected_header not in body
715
+ assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is False
716
+
717
+
718
+ @pytest.mark.parametrize(
719
+ "comment_type,round_num,expected_header",
720
+ [
721
+ ("review-summary", 1, "## Review Summary (Round 1)"),
722
+ ("fix-report", 1, "## Fix Report (Round 1)"),
723
+ ("final-report", 1, "## Final Report"),
724
+ ],
725
+ )
726
+ def test_check_existing_comment_false_when_runid_mismatched(
727
+ monkeypatch: pytest.MonkeyPatch, comment_type: str, round_num: int, expected_header: str
728
+ ) -> None:
729
+ pr_number = 101
730
+ run_id = "run-a"
731
+ other_run_id = "run-b"
732
+
733
+ body = "\n".join([_MARKER, "", expected_header, "", f"RunId: {other_run_id}"])
734
+ _patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}])
735
+
736
+ assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is False
737
+
738
+
739
+ def test_check_existing_comment_false_when_subprocess_run_nonzero(monkeypatch: pytest.MonkeyPatch) -> None:
740
+ pr_number = 999
741
+ run_id = "run-nonzero"
742
+ round_num = 2
743
+ comment_type = "review-summary"
744
+ body = "\n".join([_MARKER, "", "## Review Summary (Round 2)", "", f"RunId: {run_id}"])
745
+
746
+ _patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}], returncode=1)
747
+ assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is False
748
+
749
+
750
+ if __name__ == "__main__":
751
+ _ = pytest.main([__file__, "-v"])