claude-dev-env 1.36.1 → 1.37.0
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/_shared/pr-loop/audit-contract.md +159 -0
- package/_shared/pr-loop/code-rules-gate.md +64 -0
- package/_shared/pr-loop/fix-protocol.md +37 -0
- package/_shared/pr-loop/gh-payloads.md +85 -0
- package/_shared/pr-loop/scripts/README.md +20 -0
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +234 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +975 -0
- package/_shared/pr-loop/scripts/config/__init__.py +0 -0
- package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +36 -0
- package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +11 -0
- package/_shared/pr-loop/scripts/config/code_rules_gate_constants.py +56 -0
- package/_shared/pr-loop/scripts/config/fix_hookspath_constants.py +25 -0
- package/_shared/pr-loop/scripts/config/gh_util_constants.py +31 -0
- package/_shared/pr-loop/scripts/config/preflight_constants.py +68 -0
- package/_shared/pr-loop/scripts/fix_hookspath.py +260 -0
- package/_shared/pr-loop/scripts/gh_util.py +193 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +130 -0
- package/_shared/pr-loop/scripts/preflight.py +449 -0
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +156 -0
- package/_shared/pr-loop/scripts/tests/conftest.py +51 -0
- package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +135 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +169 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +58 -0
- package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +50 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +917 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +102 -0
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +374 -0
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +47 -0
- package/_shared/pr-loop/scripts/tests/test_gh_util.py +257 -0
- package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +61 -0
- package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +49 -0
- package/_shared/pr-loop/scripts/tests/test_preflight.py +670 -0
- package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +77 -0
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +49 -0
- package/_shared/pr-loop/state-schema.md +81 -0
- package/hooks/blocking/code_rules_enforcer.py +269 -23
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +157 -1
- package/hooks/config/test_unused_module_import_constants.py +48 -0
- package/hooks/config/unused_module_import_constants.py +41 -0
- package/package.json +2 -1
- package/skills/bg-agent/SKILL.md +69 -0
- package/skills/bugteam/CONSTRAINTS.md +10 -19
- package/skills/bugteam/PROMPTS.md +3 -3
- package/skills/bugteam/SKILL.md +103 -202
- package/skills/bugteam/SKILL_EVALS.md +75 -114
- package/skills/bugteam/reference/README.md +2 -4
- package/skills/bugteam/reference/design-rationale.md +3 -8
- package/skills/bugteam/reference/team-setup.md +11 -19
- package/skills/bugteam/reference/teardown-publish-permissions.md +2 -14
- package/skills/bugteam/scripts/config/__init__.py +0 -0
- package/skills/bugteam/scripts/config/reflow_skill_md_constants.py +12 -0
- package/skills/bugteam/scripts/reflow_skill_md.py +51 -47
- package/skills/bugteam/sources.md +1 -25
- package/skills/bugteam/test_skill_additions.py +4 -13
- package/skills/fresh-branch/SKILL.md +71 -0
- package/skills/gotcha/SKILL.md +73 -0
- package/skills/monitor-open-prs/SKILL.md +4 -37
- package/skills/monitor-open-prs/test_skill_contract.py +0 -5
- package/skills/pr-converge/SKILL.md +60 -1298
- package/skills/pr-converge/reference/convergence-gates.md +118 -0
- package/skills/pr-converge/reference/examples.md +76 -0
- package/skills/pr-converge/reference/fix-protocol.md +54 -0
- package/skills/pr-converge/reference/ground-rules.md +13 -0
- package/skills/pr-converge/reference/multi-pr-orchestration.md +204 -0
- package/skills/pr-converge/reference/per-tick.md +201 -0
- package/skills/pr-converge/reference/state-schema.md +19 -0
- package/skills/pr-converge/reference/stop-conditions.md +26 -0
- package/skills/pr-converge/scripts/README.md +36 -9
- package/skills/pr-converge/scripts/check_pr_mergeability.py +1 -2
- package/skills/pr-converge/scripts/config/pr_converge_constants.py +58 -5
- package/skills/pr-converge/scripts/config/reflow_skill_md_constants.py +13 -0
- package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +0 -24
- package/skills/pr-converge/scripts/cursor-agents-continue.ahk +22 -2
- package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +19 -59
- package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +15 -61
- package/skills/pr-converge/scripts/fetch_claude_inline_comments.py +70 -0
- package/skills/pr-converge/scripts/fetch_claude_reviews.py +61 -0
- package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +19 -61
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +14 -74
- package/skills/pr-converge/scripts/reflow_skill_md.py +71 -50
- package/skills/pr-converge/scripts/reviewer_fetch_core.py +153 -0
- package/skills/pr-converge/scripts/reviewer_specs.py +98 -0
- package/skills/pr-converge/scripts/test_cursor_agents_continue.py +65 -0
- package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +107 -6
- package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +85 -6
- package/skills/pr-converge/scripts/test_fetch_claude_inline_comments.py +485 -0
- package/skills/pr-converge/scripts/test_fetch_claude_reviews.py +368 -0
- package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +74 -6
- package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +94 -8
- package/skills/pr-converge/scripts/test_reflow_skill_md.py +162 -0
- package/skills/pr-converge/scripts/test_reviewer_fetch_core.py +448 -0
- package/skills/pr-converge/scripts/test_reviewer_specs.py +107 -0
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +24 -22
- package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +0 -113
- package/skills/bugteam/reference/workflow-path-b-task-harness.md +0 -48
- package/skills/bugteam/test_team_lifecycle.py +0 -103
- package/skills/monitor-open-prs/test_team_lifecycle.py +0 -46
- package/skills/pr-converge/scripts/open_followup_copilot_pr.py +0 -136
- package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +0 -236
- package/skills/pr-converge/test_team_lifecycle.py +0 -56
- package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +0 -108
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Tests for reflow_skill_md.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- wrap_long_bash_line returns unchanged when indent leaves zero/negative width
|
|
5
|
+
- wrap_long_bash_fence_lines handles deeply-indented bash content safely
|
|
6
|
+
- structural string literals are imported from config, not inline
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib.util
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from types import ModuleType
|
|
15
|
+
|
|
16
|
+
_SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _load_module() -> ModuleType:
|
|
20
|
+
if str(_SCRIPTS_DIRECTORY) not in sys.path:
|
|
21
|
+
sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
|
|
22
|
+
module_path = _SCRIPTS_DIRECTORY / "reflow_skill_md.py"
|
|
23
|
+
spec = importlib.util.spec_from_file_location("reflow_skill_md", module_path)
|
|
24
|
+
assert spec is not None
|
|
25
|
+
assert spec.loader is not None
|
|
26
|
+
module = importlib.util.module_from_spec(spec)
|
|
27
|
+
spec.loader.exec_module(module)
|
|
28
|
+
return module
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
reflow_module = _load_module()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_should_preserve_pr_converge_state_json_path_in_yaml_description() -> None:
|
|
35
|
+
lines = [
|
|
36
|
+
" Multi-PR runs persist traffic in",
|
|
37
|
+
" `<TMPDIR>/pr-converge-<session_id>/state.json` per Multi-PR",
|
|
38
|
+
" orchestration model.",
|
|
39
|
+
"---",
|
|
40
|
+
]
|
|
41
|
+
all_reflowed_lines, next_index = reflow_module.reflow_yaml_description_block(lines, 0)
|
|
42
|
+
reflowed_description = " ".join(each_line.strip() for each_line in all_reflowed_lines)
|
|
43
|
+
|
|
44
|
+
assert next_index == len(lines)
|
|
45
|
+
assert "`<TMPDIR>/pr-converge-<session_id>/state.json`" in reflowed_description
|
|
46
|
+
assert "`<TMPDIR>/pr-converge-<session_id>/state.json>`" not in reflowed_description
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_wrap_long_bash_fence_lines_does_not_modify_deeply_indented_line() -> None:
|
|
50
|
+
"""When indentation >= MAX_WIDTH, return line as-is."""
|
|
51
|
+
deep_indent = " " * 85
|
|
52
|
+
long_line = deep_indent + "echo hello world this is a long command"
|
|
53
|
+
all_input_lines = ["```bash", long_line, "```"]
|
|
54
|
+
all_result_lines = reflow_module.wrap_long_bash_fence_lines(all_input_lines)
|
|
55
|
+
assert all_result_lines == ["```bash", long_line, "```"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_wrap_long_bash_fence_lines_does_not_modify_max_width_indent_line() -> None:
|
|
59
|
+
"""When indentation == MAX_WIDTH, return line as-is."""
|
|
60
|
+
deep_indent = " " * 80
|
|
61
|
+
long_line = deep_indent + "echo hello"
|
|
62
|
+
all_input_lines = ["```bash", long_line, "```"]
|
|
63
|
+
all_result_lines = reflow_module.wrap_long_bash_fence_lines(all_input_lines)
|
|
64
|
+
assert all_result_lines == ["```bash", long_line, "```"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_wrap_long_bash_fence_lines_handles_deeply_indented_bash() -> None:
|
|
68
|
+
"""Full pipeline does not hang on bash fence with extreme indentation."""
|
|
69
|
+
deep_indent = " " * 90
|
|
70
|
+
all_input_lines = [
|
|
71
|
+
"```bash",
|
|
72
|
+
deep_indent + "some_command --flag value --other long argument text here",
|
|
73
|
+
"```",
|
|
74
|
+
]
|
|
75
|
+
all_result_lines = reflow_module.wrap_long_bash_fence_lines(all_input_lines)
|
|
76
|
+
assert len(all_result_lines) == 3
|
|
77
|
+
assert all_result_lines[0] == "```bash"
|
|
78
|
+
assert all_result_lines[2] == "```"
|
|
79
|
+
assert all_result_lines[1] == all_input_lines[1]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_is_new_logical_line_recognizes_fence_via_constant() -> None:
|
|
83
|
+
"""Code fence detection uses the imported constant marker."""
|
|
84
|
+
assert reflow_module.is_new_logical_line("```bash") is True
|
|
85
|
+
assert reflow_module.is_new_logical_line("```") is True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_reflow_merged_line_recognizes_example_tags_via_constant() -> None:
|
|
89
|
+
"""Example tag detection uses imported constant markers."""
|
|
90
|
+
assert reflow_module.reflow_merged_line("<example>") == ["<example>"]
|
|
91
|
+
close_result = reflow_module.reflow_merged_line("</example>")
|
|
92
|
+
assert close_result == ["</example>"]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_reflow_merged_line_recognizes_yaml_delimiter_via_constant() -> None:
|
|
96
|
+
"""YAML delimiter detection uses imported constant."""
|
|
97
|
+
assert reflow_module.reflow_merged_line("---") == ["---"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_reflow_merged_line_preserves_long_markdown_reference_definition() -> None:
|
|
101
|
+
"""Lines matching reference definitions survive reflow without paragraph wrapping."""
|
|
102
|
+
long_url_token = "x" * 90
|
|
103
|
+
line = f"[bugbot-ref]: https://example.com/{long_url_token}"
|
|
104
|
+
stripped_line = line.strip()
|
|
105
|
+
maximum_width = reflow_module.MAX_WIDTH
|
|
106
|
+
assert len(stripped_line) > maximum_width
|
|
107
|
+
assert reflow_module.REF_DEF_RE.match(stripped_line) is not None
|
|
108
|
+
assert reflow_module.reflow_merged_line(line) == [stripped_line]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_reflow_bootstrap_moves_script_directory_ahead_of_shadow_config(
|
|
112
|
+
tmp_path: Path,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""sys.path bootstrap must move the script directory ahead of shadow config packages."""
|
|
115
|
+
shadow_config_directory = tmp_path / "shadow" / "config"
|
|
116
|
+
shadow_config_directory.mkdir(parents=True)
|
|
117
|
+
(shadow_config_directory / "__init__.py").write_text("", encoding="utf-8")
|
|
118
|
+
(shadow_config_directory / "pr_converge_constants.py").write_text(
|
|
119
|
+
"BROKEN = True\n", encoding="utf-8"
|
|
120
|
+
)
|
|
121
|
+
original_sys_path = list(sys.path)
|
|
122
|
+
try:
|
|
123
|
+
sys.path.insert(0, str(tmp_path / "shadow"))
|
|
124
|
+
loaded_module = _load_module()
|
|
125
|
+
assert loaded_module.MAX_WIDTH == 80
|
|
126
|
+
assert sys.path[0] == str(_SCRIPTS_DIRECTORY)
|
|
127
|
+
assert sys.path.count(str(_SCRIPTS_DIRECTORY)) == 1
|
|
128
|
+
finally:
|
|
129
|
+
sys.path[:] = original_sys_path
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_wrap_long_bash_fence_lines_uses_continuation_marker_for_long_lines() -> None:
|
|
133
|
+
"""Wrapped continuation lines use the bash continuation marker."""
|
|
134
|
+
long_line = "echo " + "word " * 20
|
|
135
|
+
all_input_lines = ["```bash", long_line, "```"]
|
|
136
|
+
all_result_lines = reflow_module.wrap_long_bash_fence_lines(all_input_lines)
|
|
137
|
+
wrapped_content = all_result_lines[1:-1]
|
|
138
|
+
assert len(wrapped_content) > 1, "Line must be long enough to wrap"
|
|
139
|
+
assert wrapped_content[-1].lstrip(), "Final segment must have content"
|
|
140
|
+
assert all(len(each) <= reflow_module.MAX_WIDTH for each in wrapped_content), (
|
|
141
|
+
"All wrapped lines must be within MAX_WIDTH"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_reflow_uses_config_constant_for_continuation_marker_width() -> None:
|
|
146
|
+
"""The bash continuation marker width must come from config, not inline."""
|
|
147
|
+
module_path = _SCRIPTS_DIRECTORY / "reflow_skill_md.py"
|
|
148
|
+
source = module_path.read_text(encoding="utf-8")
|
|
149
|
+
assert "BASH_CONTINUATION_MARKER_WIDTH" in source, (
|
|
150
|
+
"reflow_skill_md.py must import BASH_CONTINUATION_MARKER_WIDTH from config"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def test_reflow_bootstrap_matches_code_rules_sys_path_pattern() -> None:
|
|
154
|
+
"""Bootstrap must guard insert with a membership check."""
|
|
155
|
+
module_path = _SCRIPTS_DIRECTORY / "reflow_skill_md.py"
|
|
156
|
+
source = module_path.read_text(encoding="utf-8")
|
|
157
|
+
assert "while script_directory in sys.path:" in source, (
|
|
158
|
+
"Bootstrap must dedup script_directory entries before insert"
|
|
159
|
+
)
|
|
160
|
+
assert "sys.path.insert(0, script_directory)" in source, (
|
|
161
|
+
"Bootstrap must insert script_directory at index 0"
|
|
162
|
+
)
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""Tests for reviewer_fetch_core.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- fetch_reviewer_reviews invokes gh against the reviews endpoint with --paginate --slurp
|
|
5
|
+
- login filter applies case-insensitively as a substring on user.login
|
|
6
|
+
- entries missing submitted_at or id are filtered out
|
|
7
|
+
- reviews are sorted newest-first by submitted_at
|
|
8
|
+
- the spec.classify_review callable is invoked for each surviving review
|
|
9
|
+
- subprocess errors propagate
|
|
10
|
+
- fetch_reviewer_inline_comments returns empty when no review for current_head
|
|
11
|
+
- fetch_reviewer_inline_comments only returns comments anchored to the latest review
|
|
12
|
+
- fetch_reviewer_inline_comments invokes gh against the comments endpoint with --paginate --slurp
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import importlib.util
|
|
18
|
+
import json
|
|
19
|
+
import subprocess
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from types import ModuleType
|
|
22
|
+
from unittest.mock import MagicMock, patch
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_module() -> ModuleType:
|
|
26
|
+
module_path = Path(__file__).parent / "reviewer_fetch_core.py"
|
|
27
|
+
spec = importlib.util.spec_from_file_location("reviewer_fetch_core", module_path)
|
|
28
|
+
assert spec is not None
|
|
29
|
+
assert spec.loader is not None
|
|
30
|
+
module = importlib.util.module_from_spec(spec)
|
|
31
|
+
spec.loader.exec_module(module)
|
|
32
|
+
return module
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
reviewer_fetch_core_module = _load_module()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _completed(stdout: str) -> subprocess.CompletedProcess:
|
|
39
|
+
process = MagicMock(spec=subprocess.CompletedProcess)
|
|
40
|
+
process.stdout = stdout
|
|
41
|
+
process.returncode = 0
|
|
42
|
+
return process
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _fake_spec(*, login_filter_substring: str = "test") -> object:
|
|
46
|
+
fake_spec_object = MagicMock()
|
|
47
|
+
fake_spec_object.login_filter_substring = login_filter_substring
|
|
48
|
+
fake_spec_object.classify_review = MagicMock(return_value="clean")
|
|
49
|
+
return fake_spec_object
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_fetch_reviewer_reviews_invokes_gh_with_paginate_slurp_against_reviews_endpoint() -> (
|
|
53
|
+
None
|
|
54
|
+
):
|
|
55
|
+
pages_payload = json.dumps([[]])
|
|
56
|
+
with patch("subprocess.run") as mock_run:
|
|
57
|
+
mock_run.return_value = _completed(pages_payload)
|
|
58
|
+
reviewer_fetch_core_module.fetch_reviewer_reviews(
|
|
59
|
+
_fake_spec(), owner="acme", repo="widget", number=42
|
|
60
|
+
)
|
|
61
|
+
invoked_argv = mock_run.call_args[0][0]
|
|
62
|
+
assert invoked_argv[0] == "gh"
|
|
63
|
+
assert invoked_argv[1] == "api"
|
|
64
|
+
assert "repos/acme/widget/pulls/42/reviews?per_page=100" in invoked_argv[2]
|
|
65
|
+
assert "--paginate" in invoked_argv
|
|
66
|
+
assert "--slurp" in invoked_argv
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_fetch_reviewer_reviews_filters_by_login_filter_substring_case_insensitively() -> (
|
|
70
|
+
None
|
|
71
|
+
):
|
|
72
|
+
pages_payload = json.dumps(
|
|
73
|
+
[
|
|
74
|
+
[
|
|
75
|
+
{
|
|
76
|
+
"id": 1,
|
|
77
|
+
"user": {"login": "TestBot[bot]"},
|
|
78
|
+
"state": "APPROVED",
|
|
79
|
+
"commit_id": "abc",
|
|
80
|
+
"submitted_at": "2026-01-01T00:00:00Z",
|
|
81
|
+
"body": "uppercase login",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"id": 2,
|
|
85
|
+
"user": {"login": "other-reviewer"},
|
|
86
|
+
"state": "APPROVED",
|
|
87
|
+
"commit_id": "abc",
|
|
88
|
+
"submitted_at": "2026-01-02T00:00:00Z",
|
|
89
|
+
"body": "no match",
|
|
90
|
+
},
|
|
91
|
+
]
|
|
92
|
+
]
|
|
93
|
+
)
|
|
94
|
+
with patch("subprocess.run") as mock_run:
|
|
95
|
+
mock_run.return_value = _completed(pages_payload)
|
|
96
|
+
all_reviews = reviewer_fetch_core_module.fetch_reviewer_reviews(
|
|
97
|
+
_fake_spec(login_filter_substring="test"),
|
|
98
|
+
owner="acme",
|
|
99
|
+
repo="widget",
|
|
100
|
+
number=42,
|
|
101
|
+
)
|
|
102
|
+
assert len(all_reviews) == 1
|
|
103
|
+
assert all_reviews[0]["review_id"] == 1
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_fetch_reviewer_reviews_drops_entries_missing_submitted_at() -> None:
|
|
107
|
+
pages_payload = json.dumps(
|
|
108
|
+
[
|
|
109
|
+
[
|
|
110
|
+
{
|
|
111
|
+
"id": 1,
|
|
112
|
+
"user": {"login": "test[bot]"},
|
|
113
|
+
"state": "APPROVED",
|
|
114
|
+
"commit_id": "abc",
|
|
115
|
+
"body": "no submitted_at",
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"id": 2,
|
|
119
|
+
"user": {"login": "test[bot]"},
|
|
120
|
+
"state": "APPROVED",
|
|
121
|
+
"commit_id": "abc",
|
|
122
|
+
"submitted_at": "2026-01-02T00:00:00Z",
|
|
123
|
+
"body": "valid",
|
|
124
|
+
},
|
|
125
|
+
]
|
|
126
|
+
]
|
|
127
|
+
)
|
|
128
|
+
with patch("subprocess.run") as mock_run:
|
|
129
|
+
mock_run.return_value = _completed(pages_payload)
|
|
130
|
+
all_reviews = reviewer_fetch_core_module.fetch_reviewer_reviews(
|
|
131
|
+
_fake_spec(), owner="acme", repo="widget", number=42
|
|
132
|
+
)
|
|
133
|
+
assert [each_review["review_id"] for each_review in all_reviews] == [2]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_fetch_reviewer_reviews_drops_entries_missing_id() -> None:
|
|
137
|
+
pages_payload = json.dumps(
|
|
138
|
+
[
|
|
139
|
+
[
|
|
140
|
+
{
|
|
141
|
+
"user": {"login": "test[bot]"},
|
|
142
|
+
"state": "APPROVED",
|
|
143
|
+
"commit_id": "abc",
|
|
144
|
+
"submitted_at": "2026-01-01T00:00:00Z",
|
|
145
|
+
"body": "no id",
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"id": 99,
|
|
149
|
+
"user": {"login": "test[bot]"},
|
|
150
|
+
"state": "APPROVED",
|
|
151
|
+
"commit_id": "abc",
|
|
152
|
+
"submitted_at": "2026-01-02T00:00:00Z",
|
|
153
|
+
"body": "valid",
|
|
154
|
+
},
|
|
155
|
+
]
|
|
156
|
+
]
|
|
157
|
+
)
|
|
158
|
+
with patch("subprocess.run") as mock_run:
|
|
159
|
+
mock_run.return_value = _completed(pages_payload)
|
|
160
|
+
all_reviews = reviewer_fetch_core_module.fetch_reviewer_reviews(
|
|
161
|
+
_fake_spec(), owner="acme", repo="widget", number=42
|
|
162
|
+
)
|
|
163
|
+
assert [each_review["review_id"] for each_review in all_reviews] == [99]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_fetch_reviewer_reviews_sorts_newest_first_across_pages() -> None:
|
|
167
|
+
pages_payload = json.dumps(
|
|
168
|
+
[
|
|
169
|
+
[
|
|
170
|
+
{
|
|
171
|
+
"id": 10,
|
|
172
|
+
"user": {"login": "test[bot]"},
|
|
173
|
+
"state": "APPROVED",
|
|
174
|
+
"commit_id": "old",
|
|
175
|
+
"submitted_at": "2026-01-01T00:00:00Z",
|
|
176
|
+
"body": "oldest",
|
|
177
|
+
}
|
|
178
|
+
],
|
|
179
|
+
[
|
|
180
|
+
{
|
|
181
|
+
"id": 11,
|
|
182
|
+
"user": {"login": "test[bot]"},
|
|
183
|
+
"state": "CHANGES_REQUESTED",
|
|
184
|
+
"commit_id": "new",
|
|
185
|
+
"submitted_at": "2026-01-03T00:00:00Z",
|
|
186
|
+
"body": "newest",
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
"id": 12,
|
|
190
|
+
"user": {"login": "test[bot]"},
|
|
191
|
+
"state": "APPROVED",
|
|
192
|
+
"commit_id": "mid",
|
|
193
|
+
"submitted_at": "2026-01-02T00:00:00Z",
|
|
194
|
+
"body": "middle",
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
]
|
|
198
|
+
)
|
|
199
|
+
with patch("subprocess.run") as mock_run:
|
|
200
|
+
mock_run.return_value = _completed(pages_payload)
|
|
201
|
+
all_reviews = reviewer_fetch_core_module.fetch_reviewer_reviews(
|
|
202
|
+
_fake_spec(), owner="acme", repo="widget", number=42
|
|
203
|
+
)
|
|
204
|
+
assert [each_review["submitted_at"] for each_review in all_reviews] == [
|
|
205
|
+
"2026-01-03T00:00:00Z",
|
|
206
|
+
"2026-01-02T00:00:00Z",
|
|
207
|
+
"2026-01-01T00:00:00Z",
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_fetch_reviewer_reviews_invokes_classify_callable_per_review() -> None:
|
|
212
|
+
pages_payload = json.dumps(
|
|
213
|
+
[
|
|
214
|
+
[
|
|
215
|
+
{
|
|
216
|
+
"id": 1,
|
|
217
|
+
"user": {"login": "test[bot]"},
|
|
218
|
+
"state": "APPROVED",
|
|
219
|
+
"commit_id": "abc",
|
|
220
|
+
"submitted_at": "2026-01-01T00:00:00Z",
|
|
221
|
+
"body": "first",
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
"id": 2,
|
|
225
|
+
"user": {"login": "test[bot]"},
|
|
226
|
+
"state": "CHANGES_REQUESTED",
|
|
227
|
+
"commit_id": "abc",
|
|
228
|
+
"submitted_at": "2026-01-02T00:00:00Z",
|
|
229
|
+
"body": "second",
|
|
230
|
+
},
|
|
231
|
+
]
|
|
232
|
+
]
|
|
233
|
+
)
|
|
234
|
+
classify_callable = MagicMock(side_effect=["dirty", "clean"])
|
|
235
|
+
fake_spec_object = MagicMock()
|
|
236
|
+
fake_spec_object.login_filter_substring = "test"
|
|
237
|
+
fake_spec_object.classify_review = classify_callable
|
|
238
|
+
with patch("subprocess.run") as mock_run:
|
|
239
|
+
mock_run.return_value = _completed(pages_payload)
|
|
240
|
+
all_reviews = reviewer_fetch_core_module.fetch_reviewer_reviews(
|
|
241
|
+
fake_spec_object, owner="acme", repo="widget", number=42
|
|
242
|
+
)
|
|
243
|
+
assert classify_callable.call_count == 2
|
|
244
|
+
assert {each_review["classification"] for each_review in all_reviews} == {
|
|
245
|
+
"dirty",
|
|
246
|
+
"clean",
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_fetch_reviewer_reviews_propagates_subprocess_errors() -> None:
|
|
251
|
+
failure = subprocess.CalledProcessError(
|
|
252
|
+
returncode=1, cmd=["gh"], stderr="auth failure"
|
|
253
|
+
)
|
|
254
|
+
with patch("subprocess.run", side_effect=failure):
|
|
255
|
+
try:
|
|
256
|
+
reviewer_fetch_core_module.fetch_reviewer_reviews(
|
|
257
|
+
_fake_spec(), owner="acme", repo="widget", number=42
|
|
258
|
+
)
|
|
259
|
+
assert False, "expected CalledProcessError"
|
|
260
|
+
except subprocess.CalledProcessError:
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_fetch_reviewer_inline_comments_returns_empty_when_no_review_for_head() -> None:
|
|
265
|
+
no_matching_review = [
|
|
266
|
+
{
|
|
267
|
+
"review_id": 1,
|
|
268
|
+
"commit_id": "other_sha",
|
|
269
|
+
"submitted_at": "2026-01-01T00:00:00Z",
|
|
270
|
+
"state": "APPROVED",
|
|
271
|
+
"body": "",
|
|
272
|
+
"classification": "clean",
|
|
273
|
+
}
|
|
274
|
+
]
|
|
275
|
+
with patch("subprocess.run") as mock_run:
|
|
276
|
+
all_inline_comments = reviewer_fetch_core_module.fetch_reviewer_inline_comments(
|
|
277
|
+
_fake_spec(),
|
|
278
|
+
owner="acme",
|
|
279
|
+
repo="widget",
|
|
280
|
+
number=42,
|
|
281
|
+
current_head="missing_sha",
|
|
282
|
+
all_reviews=no_matching_review,
|
|
283
|
+
)
|
|
284
|
+
assert all_inline_comments == []
|
|
285
|
+
mock_run.assert_not_called()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_fetch_reviewer_inline_comments_invokes_gh_against_comments_endpoint() -> None:
|
|
289
|
+
pages_payload = json.dumps([[]])
|
|
290
|
+
matching_review = [
|
|
291
|
+
{
|
|
292
|
+
"review_id": 1,
|
|
293
|
+
"commit_id": "abc123",
|
|
294
|
+
"submitted_at": "2026-01-01T00:00:00Z",
|
|
295
|
+
"state": "APPROVED",
|
|
296
|
+
"body": "",
|
|
297
|
+
"classification": "clean",
|
|
298
|
+
}
|
|
299
|
+
]
|
|
300
|
+
with patch("subprocess.run") as mock_run:
|
|
301
|
+
mock_run.return_value = _completed(pages_payload)
|
|
302
|
+
reviewer_fetch_core_module.fetch_reviewer_inline_comments(
|
|
303
|
+
_fake_spec(),
|
|
304
|
+
owner="acme",
|
|
305
|
+
repo="widget",
|
|
306
|
+
number=42,
|
|
307
|
+
current_head="abc123",
|
|
308
|
+
all_reviews=matching_review,
|
|
309
|
+
)
|
|
310
|
+
invoked_argv = mock_run.call_args[0][0]
|
|
311
|
+
assert invoked_argv[0] == "gh"
|
|
312
|
+
assert invoked_argv[1] == "api"
|
|
313
|
+
assert "repos/acme/widget/pulls/42/comments?per_page=100" in invoked_argv[2]
|
|
314
|
+
assert "--paginate" in invoked_argv
|
|
315
|
+
assert "--slurp" in invoked_argv
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_fetch_reviewer_inline_comments_anchors_to_latest_review_id_for_head() -> None:
|
|
319
|
+
reviews_newest_first = [
|
|
320
|
+
{
|
|
321
|
+
"review_id": 11,
|
|
322
|
+
"commit_id": "same_sha",
|
|
323
|
+
"submitted_at": "2026-01-02T00:00:00Z",
|
|
324
|
+
"state": "APPROVED",
|
|
325
|
+
"body": "lgtm",
|
|
326
|
+
"classification": "clean",
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
"review_id": 10,
|
|
330
|
+
"commit_id": "same_sha",
|
|
331
|
+
"submitted_at": "2026-01-01T00:00:00Z",
|
|
332
|
+
"state": "CHANGES_REQUESTED",
|
|
333
|
+
"body": "fix",
|
|
334
|
+
"classification": "dirty",
|
|
335
|
+
},
|
|
336
|
+
]
|
|
337
|
+
pages_payload = json.dumps(
|
|
338
|
+
[
|
|
339
|
+
[
|
|
340
|
+
{
|
|
341
|
+
"id": 100,
|
|
342
|
+
"user": {"login": "test[bot]"},
|
|
343
|
+
"commit_id": "same_sha",
|
|
344
|
+
"pull_request_review_id": 10,
|
|
345
|
+
"body": "stale",
|
|
346
|
+
"path": "x.py",
|
|
347
|
+
"line": 1,
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
"id": 101,
|
|
351
|
+
"user": {"login": "test[bot]"},
|
|
352
|
+
"commit_id": "same_sha",
|
|
353
|
+
"pull_request_review_id": 11,
|
|
354
|
+
"body": "current",
|
|
355
|
+
"path": "x.py",
|
|
356
|
+
"line": 2,
|
|
357
|
+
},
|
|
358
|
+
]
|
|
359
|
+
]
|
|
360
|
+
)
|
|
361
|
+
with patch("subprocess.run") as mock_run:
|
|
362
|
+
mock_run.return_value = _completed(pages_payload)
|
|
363
|
+
all_inline_comments = reviewer_fetch_core_module.fetch_reviewer_inline_comments(
|
|
364
|
+
_fake_spec(),
|
|
365
|
+
owner="acme",
|
|
366
|
+
repo="widget",
|
|
367
|
+
number=42,
|
|
368
|
+
current_head="same_sha",
|
|
369
|
+
all_reviews=reviews_newest_first,
|
|
370
|
+
)
|
|
371
|
+
assert [each_comment["comment_id"] for each_comment in all_inline_comments] == [101]
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def test_fetch_reviewer_inline_comments_filters_login_substring() -> None:
|
|
375
|
+
matching_review = [
|
|
376
|
+
{
|
|
377
|
+
"review_id": 9,
|
|
378
|
+
"commit_id": "abc",
|
|
379
|
+
"submitted_at": "2026-01-01T00:00:00Z",
|
|
380
|
+
"state": "APPROVED",
|
|
381
|
+
"body": "",
|
|
382
|
+
"classification": "clean",
|
|
383
|
+
}
|
|
384
|
+
]
|
|
385
|
+
pages_payload = json.dumps(
|
|
386
|
+
[
|
|
387
|
+
[
|
|
388
|
+
{
|
|
389
|
+
"id": 1,
|
|
390
|
+
"user": {"login": "test[bot]"},
|
|
391
|
+
"commit_id": "abc",
|
|
392
|
+
"pull_request_review_id": 9,
|
|
393
|
+
"body": "match",
|
|
394
|
+
"path": "f.py",
|
|
395
|
+
"line": 1,
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
"id": 2,
|
|
399
|
+
"user": {"login": "other-reviewer"},
|
|
400
|
+
"commit_id": "abc",
|
|
401
|
+
"pull_request_review_id": 9,
|
|
402
|
+
"body": "no match",
|
|
403
|
+
"path": "f.py",
|
|
404
|
+
"line": 2,
|
|
405
|
+
},
|
|
406
|
+
]
|
|
407
|
+
]
|
|
408
|
+
)
|
|
409
|
+
with patch("subprocess.run") as mock_run:
|
|
410
|
+
mock_run.return_value = _completed(pages_payload)
|
|
411
|
+
all_inline_comments = reviewer_fetch_core_module.fetch_reviewer_inline_comments(
|
|
412
|
+
_fake_spec(login_filter_substring="test"),
|
|
413
|
+
owner="acme",
|
|
414
|
+
repo="widget",
|
|
415
|
+
number=42,
|
|
416
|
+
current_head="abc",
|
|
417
|
+
all_reviews=matching_review,
|
|
418
|
+
)
|
|
419
|
+
assert [each_comment["comment_id"] for each_comment in all_inline_comments] == [1]
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def test_fetch_reviewer_inline_comments_propagates_subprocess_errors() -> None:
|
|
423
|
+
matching_review = [
|
|
424
|
+
{
|
|
425
|
+
"review_id": 1,
|
|
426
|
+
"commit_id": "abc",
|
|
427
|
+
"submitted_at": "2026-01-01T00:00:00Z",
|
|
428
|
+
"state": "APPROVED",
|
|
429
|
+
"body": "",
|
|
430
|
+
"classification": "clean",
|
|
431
|
+
}
|
|
432
|
+
]
|
|
433
|
+
failure = subprocess.CalledProcessError(
|
|
434
|
+
returncode=1, cmd=["gh"], stderr="auth failure"
|
|
435
|
+
)
|
|
436
|
+
with patch("subprocess.run", side_effect=failure):
|
|
437
|
+
try:
|
|
438
|
+
reviewer_fetch_core_module.fetch_reviewer_inline_comments(
|
|
439
|
+
_fake_spec(),
|
|
440
|
+
owner="acme",
|
|
441
|
+
repo="widget",
|
|
442
|
+
number=42,
|
|
443
|
+
current_head="abc",
|
|
444
|
+
all_reviews=matching_review,
|
|
445
|
+
)
|
|
446
|
+
assert False, "expected CalledProcessError"
|
|
447
|
+
except subprocess.CalledProcessError:
|
|
448
|
+
pass
|